mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11ee0fef5d | ||
|
|
9a9ba34717 | ||
|
|
312e47bf46 | ||
|
|
628865fd06 | ||
|
|
806a03cd53 | ||
|
|
24bd90fcf6 | ||
|
|
d2765577c8 | ||
|
|
60ca688bcb | ||
|
|
76d8eea41d | ||
|
|
635c3a04d8 | ||
|
|
dde97abe38 | ||
|
|
90a22d894d | ||
|
|
88ef9cd6ae | ||
|
|
e3595b5c57 | ||
|
|
ce82f87e43 | ||
|
|
854b291c5a | ||
|
|
9780fd059c | ||
|
|
adc65f66eb | ||
|
|
ae772074a1 | ||
|
|
16c1e9edd1 | ||
|
|
3ab9ffb7b7 | ||
|
|
82e2123fe7 | ||
|
|
7a65f3d2f4 | ||
|
|
b5b5d499e5 | ||
|
|
173f9e9c30 | ||
|
|
a610c72067 | ||
|
|
d210a49fae | ||
|
|
b015c248ea | ||
|
|
4a559ea770 | ||
|
|
e306751863 | ||
|
|
2f51f5f33e | ||
|
|
74a2a61fc1 | ||
|
|
b6c0345b3e | ||
|
|
6421a6f5cb | ||
|
|
daf56e5dc2 | ||
|
|
cb7c9af25c | ||
|
|
45e61befac | ||
|
|
ea50ba10e6 | ||
|
|
5c4a727e74 | ||
|
|
867f05c4ad | ||
|
|
b06b32306f | ||
|
|
dbfcb70f8d | ||
|
|
e64d56c4ac | ||
|
|
8f0da7943c | ||
|
|
e62ff7e520 | ||
|
|
86e951916e | ||
|
|
6bf08466de | ||
|
|
5e36dd480d | ||
|
|
0e2cd8c018 | ||
|
|
b4f92eba38 | ||
|
|
905e48c8ed |
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.github
|
||||
.venv
|
||||
.vscode
|
||||
.data
|
||||
.temp
|
||||
web/.next
|
||||
web/node_modules
|
||||
web/.env
|
||||
5
.github/workflows/build-docker-image.yml
vendored
5
.github/workflows/build-docker-image.yml
vendored
@@ -3,7 +3,6 @@ on:
|
||||
## 发布release的时候会自动构建
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
jobs:
|
||||
publish-docker-image:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -42,7 +41,7 @@ jobs:
|
||||
run: docker buildx create --name mybuilder --use
|
||||
- name: Build for Release # only relase, exlude pre-release
|
||||
if: ${{ github.event.release.prerelease == false }}
|
||||
run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
|
||||
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} -t rockchin/langbot:latest . --push
|
||||
- name: Build for Pre-release # no update for latest tag
|
||||
if: ${{ github.event.release.prerelease == true }}
|
||||
run: docker buildx build --platform linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push
|
||||
run: docker buildx build --platform linux/arm64,linux/amd64 -t rockchin/langbot:${{ steps.check_version.outputs.version }} . --push
|
||||
2
.github/workflows/run-tests.yml
vendored
2
.github/workflows/run-tests.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.10', '3.11', '3.12']
|
||||
python-version: ['3.11', '3.12', '3.13']
|
||||
fail-fast: false
|
||||
|
||||
steps:
|
||||
|
||||
21
AGENTS.md
21
AGENTS.md
@@ -8,16 +8,17 @@ LangBot is a open-source LLM native instant messaging bot development platform,
|
||||
|
||||
LangBot has a comprehensive frontend, all operations can be performed through the frontend. The project splited into these major parts:
|
||||
|
||||
- `./pkg`: The core python package of the project backend.
|
||||
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.
|
||||
- `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.
|
||||
- `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.
|
||||
- `./pkg/api`: The api module of the project, containing the http api controllers and services.
|
||||
- `./pkg/plugin`: LangBot bridge for connecting with plugin system.
|
||||
- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.
|
||||
- `./templates`: Templates of config files, components, etc.
|
||||
- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.
|
||||
- `./docker`: docker-compose deployment files.
|
||||
- `./src/langbot`: The main python package of the project, below are the main modules in this package:
|
||||
- `./pkg`: The core python package of the project backend.
|
||||
- `./pkg/platform`: The platform module of the project, containing the logic of message platform adapters, bot managers, message session managers, etc.
|
||||
- `./pkg/provider`: The provider module of the project, containing the logic of LLM providers, tool providers, etc.
|
||||
- `./pkg/pipeline`: The pipeline module of the project, containing the logic of pipelines, stages, query pool, etc.
|
||||
- `./pkg/api`: The api module of the project, containing the http api controllers and services.
|
||||
- `./pkg/plugin`: LangBot bridge for connecting with plugin system.
|
||||
- `./libs`: Some SDKs we previously developed for the project, such as `qq_official_api`, `wecom_api`, etc.
|
||||
- `./templates`: Templates of config files, components, etc.
|
||||
- `./web`: Frontend codebase, built with Next.js + **shadcn** + **Tailwind CSS**.
|
||||
- `./docker`: docker-compose deployment files.
|
||||
|
||||
## Backend Development
|
||||
|
||||
|
||||
@@ -20,4 +20,4 @@ RUN apt update \
|
||||
&& uv sync \
|
||||
&& touch /.dockerenv
|
||||
|
||||
CMD [ "uv", "run", "main.py" ]
|
||||
CMD [ "uv", "run", "--no-sync", "main.py" ]
|
||||
12
README.md
12
README.md
@@ -1,13 +1,15 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>使用 LangBot 快速构建、调试、部署即时通信机器人。</h3>
|
||||
|
||||
[English](README_EN.md) / 简体中文 / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -22,12 +24,10 @@
|
||||
<a href="https://docs.langbot.app/zh/plugin/plugin-intro.html">插件介绍</a> |
|
||||
<a href="https://github.com/langbot-app/LangBot/issues/new?assignees=&labels=%E7%8B%AC%E7%AB%8B%E6%8F%92%E4%BB%B6&projects=&template=submit-plugin.yml&title=%5BPlugin%5D%3A+%E8%AF%B7%E6%B1%82%E7%99%BB%E8%AE%B0%E6%96%B0%E6%8F%92%E4%BB%B6">提交插件</a>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</p>
|
||||
|
||||
LangBot 是一个开源的大语言模型原生即时通信机器人开发平台,旨在提供开箱即用的 IM 机器人开发体验,具有 Agent、RAG、MCP 等多种 LLM 应用功能,适配全球主流即时通信平台,并提供丰富的 API 接口,支持自定义开发。
|
||||
|
||||
## 📦 开始使用
|
||||
|
||||
@@ -83,8 +83,11 @@ docker compose up -d
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-zh-rounded.png" />
|
||||
|
||||
|
||||
- 💬 大模型对话、Agent:支持多种大模型,适配群聊和私聊;具有多轮对话、工具调用、多模态、流式输出能力,自带 RAG(知识库)实现,并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)等 LLMOps 平台。
|
||||
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram 等平台。
|
||||
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram、KOOK、Slack、LINE 等平台。
|
||||
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。
|
||||
- 🧩 插件扩展、活跃社区:高稳定性、高安全性的生产级插件系统,支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
|
||||
- 😻 Web 管理面板:支持通过浏览器管理 LangBot 实例,不再需要手动编写配置文件。
|
||||
@@ -108,6 +111,7 @@ docker compose up -d
|
||||
| 微信公众号 | ✅ | |
|
||||
| 飞书 | ✅ | |
|
||||
| 钉钉 | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
|
||||
11
README_EN.md
11
README_EN.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>Quickly build, debug, and ship IM bots with LangBot.</h3>
|
||||
|
||||
English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -23,7 +25,6 @@ English / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語]
|
||||
|
||||
</p>
|
||||
|
||||
LangBot is an open-source LLM native instant messaging robot development platform, aiming to provide out-of-the-box IM robot development experience, with Agent, RAG, MCP and other LLM application functions, adapting to global instant messaging platforms, and providing rich API interfaces, supporting custom development.
|
||||
|
||||
## 📦 Getting Started
|
||||
|
||||
@@ -79,8 +80,11 @@ Click the Star and Watch button in the upper right corner of the repository to g
|
||||
|
||||
## ✨ Features
|
||||
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, multi-modal, and streaming output capabilities. Built-in RAG (knowledge base) implementation, and deeply integrates with [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
|
||||
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, etc.
|
||||
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
|
||||
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods. Supports multiple pipeline configurations, different bots can be used for different scenarios.
|
||||
- 🧩 Plugin Extension, Active Community: High stability, high security production-level plugin system; Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins.
|
||||
- 😻 Web UI: Support management LangBot instance through the browser. No need to manually write configuration files.
|
||||
@@ -107,6 +111,7 @@ Or visit the demo environment: https://demo.langbot.dev/
|
||||
| Personal WeChat | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
|
||||
11
README_ES.md
11
README_ES.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>Cree, depure y despliegue bots de mensajería instantánea rápidamente con LangBot.</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / Español / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -23,7 +25,6 @@
|
||||
|
||||
</p>
|
||||
|
||||
LangBot es una plataforma de desarrollo de robots de mensajería instantánea nativa de LLM de código abierto, con el objetivo de proporcionar una experiencia de desarrollo de robots de mensajería instantánea lista para usar, con funciones de aplicación LLM como Agent, RAG, MCP, adaptándose a plataformas de mensajería instantánea globales y proporcionando interfaces API ricas, compatible con desarrollo personalizado.
|
||||
|
||||
## 📦 Comenzar
|
||||
|
||||
@@ -79,8 +80,11 @@ Haga clic en los botones Star y Watch en la esquina superior derecha del reposit
|
||||
|
||||
## ✨ Características
|
||||
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 Chat con LLM / Agent: Compatible con múltiples LLMs, adaptado para chats grupales y privados; Admite conversaciones de múltiples rondas, llamadas a herramientas, capacidades multimodales y de salida en streaming. Implementación RAG (base de conocimientos) incorporada, e integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
|
||||
- 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, etc.
|
||||
- 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
|
||||
- 🛠️ Alta Estabilidad, Rico en Funciones: Control de acceso nativo, limitación de velocidad, filtrado de palabras sensibles, etc.; Fácil de usar, admite múltiples métodos de despliegue. Compatible con múltiples configuraciones de pipeline, diferentes bots para diferentes escenarios.
|
||||
- 🧩 Extensión de Plugin, Comunidad Activa: Sistema de plugin de alta estabilidad, alta seguridad de nivel de producción; Compatible con mecanismos de plugin impulsados por eventos, extensión de componentes, etc.; Integración del protocolo [MCP](https://modelcontextprotocol.io/) de Anthropic; Actualmente cuenta con cientos de plugins.
|
||||
- 😻 Interfaz Web: Admite la gestión de instancias de LangBot a través del navegador. No es necesario escribir archivos de configuración manualmente.
|
||||
@@ -107,6 +111,7 @@ O visite el entorno de demostración: https://demo.langbot.dev/
|
||||
| WeChat Personal | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
|
||||
12
README_FR.md
12
README_FR.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>Créez, déboguez et déployez rapidement des bots de messagerie instantanée avec LangBot.</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / Français / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -23,8 +25,6 @@
|
||||
|
||||
</p>
|
||||
|
||||
LangBot est une plateforme de développement de robots de messagerie instantanée native LLM open source, visant à fournir une expérience de développement de robots de messagerie instantanée prête à l'emploi, avec des fonctionnalités d'application LLM telles qu'Agent, RAG, MCP, s'adaptant aux plateformes de messagerie instantanée mondiales et fournissant des interfaces API riches, prenant en charge le développement personnalisé.
|
||||
|
||||
## 📦 Commencer
|
||||
|
||||
#### Démarrage Rapide
|
||||
@@ -79,8 +79,11 @@ Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt p
|
||||
|
||||
## ✨ Fonctionnalités
|
||||
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 Chat avec LLM / Agent : Prend en charge plusieurs LLM, adapté aux chats de groupe et privés ; Prend en charge les conversations multi-tours, les appels d'outils, les capacités multimodales et de sortie en streaming. Implémentation RAG (base de connaissances) intégrée, et intégration profonde avec [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
|
||||
- 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, etc.
|
||||
- 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
|
||||
- 🛠️ Haute Stabilité, Riche en Fonctionnalités : Contrôle d'accès natif, limitation de débit, filtrage de mots sensibles, etc. ; Facile à utiliser, prend en charge plusieurs méthodes de déploiement. Prend en charge plusieurs configurations de pipeline, différents bots pour différents scénarios.
|
||||
- 🧩 Extension de Plugin, Communauté Active : Système de plugin de haute stabilité, haute sécurité de niveau production; Prend en charge les mécanismes de plugin pilotés par événements, l'extension de composants, etc. ; Intégration du protocole [MCP](https://modelcontextprotocol.io/) d'Anthropic ; Dispose actuellement de centaines de plugins.
|
||||
- 😻 Interface Web : Prend en charge la gestion des instances LangBot via le navigateur. Pas besoin d'écrire manuellement les fichiers de configuration.
|
||||
@@ -107,6 +110,7 @@ Ou visitez l'environnement de démonstration : https://demo.langbot.dev/
|
||||
| WeChat Personnel | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
|
||||
12
README_JP.md
12
README_JP.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>LangBotでIMボットを素早く構築、デバッグ、デプロイ。</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / 日本語 / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -23,8 +25,6 @@
|
||||
|
||||
</p>
|
||||
|
||||
LangBot は、エージェント、RAG、MCP などの LLM アプリケーション機能を備えた、オープンソースの LLM ネイティブのインスタントメッセージングロボット開発プラットフォームです。世界中のインスタントメッセージングプラットフォームに適応し、豊富な API インターフェースを提供し、カスタム開発をサポートします。
|
||||
|
||||
## 📦 始め方
|
||||
|
||||
#### クイックスタート
|
||||
@@ -79,8 +79,11 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
|
||||
|
||||
## ✨ 機能
|
||||
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG(知識ベース)を組み込み、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) などの LLMOps プラットフォームと深く統合。
|
||||
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram など、複数のプラットフォームをサポートしています。
|
||||
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram、KOOK、Slack、LINE など、複数のプラットフォームをサポートしています。
|
||||
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。
|
||||
- 🧩 プラグイン拡張、活発なコミュニティ: 高い安定性、高いセキュリティの生産レベルのプラグインシステム;イベント駆動、コンポーネント拡張などのプラグインメカニズムをサポート。適配 Anthropic [MCP プロトコル](https://modelcontextprotocol.io/);豊富なエコシステム、現在数百のプラグインが存在。
|
||||
- 😻 Web UI: ブラウザを通じてLangBotインスタンスを管理することをサポート。
|
||||
@@ -107,6 +110,7 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
|
||||
| 個人WeChat | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
### LLMs
|
||||
|
||||
|
||||
12
README_KO.md
12
README_KO.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>LangBot으로 IM 봇을 빠르게 구축, 디버그 및 배포하세요.</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / 한국어 / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -23,8 +25,6 @@
|
||||
|
||||
</p>
|
||||
|
||||
LangBot은 오픈 소스 LLM 네이티브 인스턴트 메시징 로봇 개발 플랫폼으로, Agent, RAG, MCP 등 다양한 LLM 애플리케이션 기능을 갖춘 즉시 사용 가능한 IM 로봇 개발 경험을 제공하며, 글로벌 인스턴트 메시징 플랫폼에 적응하고 풍부한 API 인터페이스를 제공하여 맞춤형 개발을 지원합니다.
|
||||
|
||||
## 📦 시작하기
|
||||
|
||||
#### 빠른 시작
|
||||
@@ -79,8 +79,11 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
|
||||
|
||||
## ✨ 기능
|
||||
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 LLM / Agent와 채팅: 여러 LLM을 지원하며 그룹 채팅 및 개인 채팅에 적응; 멀티 라운드 대화, 도구 호출, 멀티모달, 스트리밍 출력 기능을 지원합니다. 내장된 RAG(지식 베이스) 구현 및 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 등의 LLMOps 플랫폼과 깊이 통합됩니다.
|
||||
- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram 등을 지원합니다.
|
||||
- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE 등을 지원합니다.
|
||||
- 🛠️ 높은 안정성, 풍부한 기능: 네이티브 액세스 제어, 속도 제한, 민감한 단어 필터링 등의 메커니즘; 사용하기 쉽고 여러 배포 방법을 지원합니다. 여러 파이프라인 구성을 지원하며 다양한 시나리오에 대해 다른 봇을 사용할 수 있습니다.
|
||||
- 🧩 플러그인 확장, 활발한 커뮤니티: 고안정성, 고보안 생산 수준의 플러그인 시스템; 이벤트 기반, 컴포넌트 확장 등의 플러그인 메커니즘을 지원; Anthropic [MCP 프로토콜](https://modelcontextprotocol.io/) 통합; 현재 수백 개의 플러그인이 있습니다.
|
||||
- 😻 웹 UI: 브라우저를 통해 LangBot 인스턴스 관리를 지원합니다. 구성 파일을 수동으로 작성할 필요가 없습니다.
|
||||
@@ -105,6 +108,7 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| 개인 WeChat | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
|
||||
12
README_RU.md
12
README_RU.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>Быстро создавайте, отлаживайте и развертывайте IM-ботов с LangBot.</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / Русский / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -23,8 +25,6 @@
|
||||
|
||||
</p>
|
||||
|
||||
LangBot — это платформа разработки ботов для мгновенных сообщений на основе LLM с открытым исходным кодом, целью которой является предоставление готового к использованию опыта разработки ботов для IM, с функциями приложений LLM, такими как Agent, RAG, MCP, адаптацией к глобальным платформам мгновенных сообщений и предоставлением богатых API-интерфейсов, поддерживающих пользовательскую разработку.
|
||||
|
||||
## 📦 Начало работы
|
||||
|
||||
#### Быстрый старт
|
||||
@@ -79,8 +79,11 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
|
||||
|
||||
## ✨ Функции
|
||||
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 Чат с LLM / Agent: Поддержка нескольких LLM, адаптация к групповым и личным чатам; Поддержка многораундовых разговоров, вызовов инструментов, мультимодальных возможностей и потоковой передачи. Встроенная реализация RAG (база знаний) и глубокая интеграция с [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) 등의 LLMOps 플랫포트폼과 깊이 통합됩니다.
|
||||
- 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram и т.д.
|
||||
- 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE и т.д.
|
||||
- 🛠️ Высокая стабильность, богатство функций: Нативный контроль доступа, ограничение скорости, фильтрация чувствительных слов и т.д.; Простота в использовании, поддержка нескольких методов развертывания. Поддержка нескольких конфигураций конвейера, разные боты для разных сценариев.
|
||||
- 🧩 Расширение плагинов, активное сообщество: Высокая стабильность, высокая безопасность уровня производства; Поддержка механизмов плагинов, управляемых событиями, расширения компонентов и т.д.; Интеграция протокола [MCP](https://modelcontextprotocol.io/) от Anthropic; В настоящее время сотни плагинов.
|
||||
- 😻 Веб-интерфейс: Поддержка управления экземплярами LangBot через браузер. Нет необходимости вручную писать конфигурационные файлы.
|
||||
@@ -105,6 +108,7 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| Личный WeChat | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
|
||||
12
README_TW.md
12
README_TW.md
@@ -1,10 +1,12 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_zh.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center"><a href="https://hellogithub.com/repository/langbot-app/LangBot" target="_blank"><img src="https://abroad.hellogithub.com/v1/widgets/recommend.svg?rid=5ce8ae2aa4f74316bf393b57b952433c&claim_uid=gtmc6YWjMZkT21R" alt="Featured|HelloGitHub" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>使用 LangBot 快速建構、除錯和部署 IM 機器人。</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / 繁體中文 / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / [Tiếng Việt](README_VI.md)
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -23,8 +25,6 @@
|
||||
|
||||
</p>
|
||||
|
||||
LangBot 是一個開源的大語言模型原生即時通訊機器人開發平台,旨在提供開箱即用的 IM 機器人開發體驗,具有 Agent、RAG、MCP 等多種 LLM 應用功能,適配全球主流即時通訊平台,並提供豐富的 API 介面,支援自定義開發。
|
||||
|
||||
## 📦 開始使用
|
||||
|
||||
#### 快速部署
|
||||
@@ -79,8 +79,11 @@ docker compose up -d
|
||||
|
||||
## ✨ 特性
|
||||
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 大模型對話、Agent:支援多種大模型,適配群聊和私聊;具有多輪對話、工具調用、多模態、流式輸出能力,自帶 RAG(知識庫)實現,並深度適配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 等 LLMOps 平台。
|
||||
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram 等平台。
|
||||
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram、KOOK、Slack、LINE 等平台。
|
||||
- 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。支援多流水線配置,不同機器人用於不同應用場景。
|
||||
- 🧩 外掛擴展、活躍社群:高穩定性、高安全性的生產級外掛系統;支援事件驅動、組件擴展等外掛機制;適配 Anthropic [MCP 協議](https://modelcontextprotocol.io/);目前已有數百個外掛。
|
||||
- 😻 Web 管理面板:支援通過瀏覽器管理 LangBot 實例,不再需要手動編寫配置文件。
|
||||
@@ -105,6 +108,7 @@ docker compose up -d
|
||||
| 企微對外客服 | ✅ | |
|
||||
| 企微智能機器人 | ✅ | |
|
||||
| 微信公眾號 | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
|
||||
12
README_VI.md
12
README_VI.md
@@ -1,12 +1,14 @@
|
||||
<p align="center">
|
||||
<a href="https://langbot.app">
|
||||
<img src="https://docs.langbot.app/social_en.png" alt="LangBot"/>
|
||||
<img width="130" src="https://docs.langbot.app/langbot-logo.png" alt="LangBot"/>
|
||||
</a>
|
||||
|
||||
<div align="center">
|
||||
|
||||
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
<h3>Xây dựng, gỡ lỗi và triển khai bot IM nhanh chóng với LangBot.</h3>
|
||||
|
||||
[English](README_EN.md) / [简体中文](README.md) / [繁體中文](README_TW.md) / [日本語](README_JP.md) / [Español](README_ES.md) / [Français](README_FR.md) / [한국어](README_KO.md) / [Русский](README_RU.md) / Tiếng Việt
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -23,8 +25,6 @@
|
||||
|
||||
</p>
|
||||
|
||||
LangBot là một nền tảng phát triển robot nhắn tin tức thời gốc LLM mã nguồn mở, nhằm mục đích cung cấp trải nghiệm phát triển robot IM sẵn sàng sử dụng, với các chức năng ứng dụng LLM như Agent, RAG, MCP, thích ứng với các nền tảng nhắn tin tức thời toàn cầu và cung cấp giao diện API phong phú, hỗ trợ phát triển tùy chỉnh.
|
||||
|
||||
## 📦 Bắt đầu
|
||||
|
||||
#### Khởi động Nhanh
|
||||
@@ -79,8 +79,11 @@ Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu t
|
||||
|
||||
## ✨ Tính năng
|
||||
|
||||
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
|
||||
|
||||
|
||||
- 💬 Chat với LLM / Agent: Hỗ trợ nhiều LLM, thích ứng với chat nhóm và chat riêng tư; Hỗ trợ các cuộc trò chuyện nhiều vòng, gọi công cụ, khả năng đa phương thức và đầu ra streaming. Triển khai RAG (cơ sở kiến thức) tích hợp sẵn và tích hợp sâu với [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) v.v. LLMOps platforms.
|
||||
- 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, v.v.
|
||||
- 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, v.v.
|
||||
- 🛠️ Độ ổn định Cao, Tính năng Phong phú: Kiểm soát truy cập gốc, giới hạn tốc độ, lọc từ nhạy cảm, v.v.; Dễ sử dụng, hỗ trợ nhiều phương pháp triển khai. Hỗ trợ nhiều cấu hình pipeline, các bot khác nhau cho các kịch bản khác nhau.
|
||||
- 🧩 Mở rộng Plugin, Cộng đồng Hoạt động: Hỗ trợ các cơ chế plugin hướng sự kiện, mở rộng thành phần, v.v.; Tích hợp giao thức [MCP](https://modelcontextprotocol.io/) của Anthropic; Hiện có hàng trăng plugin.
|
||||
- 😻 Giao diện Web: Hỗ trợ quản lý các phiên bản LangBot thông qua trình duyệt. Không cần viết tệp cấu hình thủ công.
|
||||
@@ -105,6 +108,7 @@ Hoặc truy cập môi trường demo: https://demo.langbot.dev/
|
||||
| WeComCS | ✅ | |
|
||||
| WeCom AI Bot | ✅ | |
|
||||
| WeChat Cá nhân | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ services:
|
||||
langbot_plugin_runtime:
|
||||
image: rockchin/langbot:latest
|
||||
container_name: langbot_plugin_runtime
|
||||
platform: linux/amd64 # For Apple Silicon compatibility
|
||||
volumes:
|
||||
- ./data/plugins:/app/data/plugins
|
||||
ports:
|
||||
@@ -22,10 +21,8 @@ services:
|
||||
langbot:
|
||||
image: rockchin/langbot:latest
|
||||
container_name: langbot
|
||||
platform: linux/amd64 # For Apple Silicon compatibility
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./plugins:/app/plugins
|
||||
restart: on-failure
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
|
||||
259
docs/SEEKDB_INTEGRATION.md
Normal file
259
docs/SEEKDB_INTEGRATION.md
Normal file
@@ -0,0 +1,259 @@
|
||||
# SeekDB Vector Database Integration
|
||||
|
||||
This document describes how to use OceanBase SeekDB as the vector database backend for LangBot's knowledge base feature.
|
||||
|
||||
## What is SeekDB?
|
||||
|
||||
**OceanBase SeekDB** is an AI-native search database that unifies relational, vector, text, JSON and GIS in a single engine, enabling hybrid search and in-database AI workflows. It's developed by OceanBase and released under Apache 2.0 license.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Hybrid Search**: Combine vector search, full-text search and relational query in a single statement
|
||||
- **Multi-Model Support**: Support relational, vector, text, JSON and GIS in a single engine
|
||||
- **Lightweight**: Requires as little as 1 CPU core and 2 GB of memory
|
||||
- **Multiple Deployment Modes**: Supports both embedded mode and client/server mode
|
||||
- **MySQL Compatible**: Powered by OceanBase engine with full ACID compliance and MySQL compatibility
|
||||
|
||||
## Installation
|
||||
|
||||
SeekDB support is automatically included when you install LangBot. The required dependency `pyseekdb` is listed in `pyproject.toml`.
|
||||
|
||||
If you need to install it manually:
|
||||
|
||||
```bash
|
||||
pip install pyseekdb
|
||||
```
|
||||
|
||||
## ⚠️ Platform Compatibility
|
||||
|
||||
### Embedded Mode
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Linux | ✅ Supported | Full embedded mode support via `pylibseekdb` |
|
||||
| macOS | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||
| Windows | ❌ Not Supported | `pylibseekdb` is Linux-only; use server mode instead |
|
||||
|
||||
**Important**: Embedded mode requires the `pylibseekdb` library, which is only available on Linux. If you're on macOS or Windows, you must use server mode.
|
||||
|
||||
### Server Mode (Docker)
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Linux | ✅ Supported | Full Docker support |
|
||||
| macOS | ⚠️ Known Issue | Docker container initialization failure - [See Issue #36](https://github.com/oceanbase/seekdb/issues/36) |
|
||||
| Windows | ⚠️ Untested | Should work but not yet tested |
|
||||
|
||||
**macOS Users**: Currently, SeekDB Docker containers have an initialization issue on macOS ([oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36)). Until this is resolved, we recommend:
|
||||
- Using ChromaDB or Qdrant as alternatives
|
||||
- Connecting to a remote SeekDB server on Linux if available
|
||||
|
||||
### Server Mode (Remote Connection)
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| All Platforms | ✅ Supported | Connect to SeekDB running on a remote Linux server |
|
||||
|
||||
**Recommendation for macOS/Windows users**: Deploy SeekDB on a Linux server and connect via server mode configuration.
|
||||
|
||||
## Configuration
|
||||
|
||||
### Embedded Mode (Recommended for Development)
|
||||
|
||||
Embedded mode runs SeekDB directly within the LangBot process, storing data locally. This is the simplest setup and requires no external services.
|
||||
|
||||
Edit your `config.yaml`:
|
||||
|
||||
```yaml
|
||||
vdb:
|
||||
use: seekdb
|
||||
seekdb:
|
||||
mode: embedded
|
||||
path: './data/seekdb' # Path to store SeekDB data
|
||||
database: 'langbot' # Database name
|
||||
```
|
||||
|
||||
### Server Mode (For Production)
|
||||
|
||||
Server mode connects to a remote SeekDB server or OceanBase server. This is recommended for production deployments.
|
||||
|
||||
#### SeekDB Server
|
||||
|
||||
```yaml
|
||||
vdb:
|
||||
use: seekdb
|
||||
seekdb:
|
||||
mode: server
|
||||
host: 'localhost'
|
||||
port: 2881
|
||||
database: 'langbot'
|
||||
user: 'root'
|
||||
password: '' # Can also use SEEKDB_PASSWORD env var
|
||||
```
|
||||
|
||||
#### OceanBase Server
|
||||
|
||||
If you're using OceanBase with seekdb capabilities:
|
||||
|
||||
```yaml
|
||||
vdb:
|
||||
use: seekdb
|
||||
seekdb:
|
||||
mode: server
|
||||
host: 'localhost'
|
||||
port: 2881
|
||||
tenant: 'sys' # OceanBase tenant name
|
||||
database: 'langbot'
|
||||
user: 'root'
|
||||
password: ''
|
||||
```
|
||||
|
||||
## Configuration Parameters
|
||||
|
||||
| Parameter | Required | Default | Description |
|
||||
|-----------|----------|--------------|-------------|
|
||||
| `mode` | No | `embedded` | Deployment mode: `embedded` or `server` |
|
||||
| `path` | No | `./data/seekdb` | Data directory for embedded mode |
|
||||
| `database` | No | `langbot` | Database name |
|
||||
| `host` | No | `localhost` | Server host (server mode only) |
|
||||
| `port` | No | `2881` | Server port (server mode only) |
|
||||
| `user` | No | `root` | Username (server mode only) |
|
||||
| `password` | No | `''` | Password (server mode only) |
|
||||
| `tenant` | No | None | OceanBase tenant (optional, server mode only) |
|
||||
|
||||
## Usage
|
||||
|
||||
Once configured, SeekDB will be used automatically for all knowledge base operations in LangBot:
|
||||
|
||||
1. **Creating Knowledge Bases**: Vectors will be stored in SeekDB collections
|
||||
2. **Adding Documents**: Document embeddings will be indexed in SeekDB
|
||||
3. **Searching**: Vector similarity search will use SeekDB's efficient indexing
|
||||
4. **Deleting**: Document removal will delete vectors from SeekDB
|
||||
|
||||
No code changes are required - just update your configuration!
|
||||
|
||||
## Architecture Details
|
||||
|
||||
### Implementation
|
||||
|
||||
The SeekDB adapter is implemented in `src/langbot/pkg/vector/vdbs/seekdb.py` and follows the same `VectorDatabase` interface as Chroma and Qdrant adapters.
|
||||
|
||||
Key methods:
|
||||
- `add_embeddings()`: Add vectors with metadata to a collection
|
||||
- `search()`: Perform vector similarity search
|
||||
- `delete_by_file_id()`: Delete vectors by file ID metadata
|
||||
- `get_or_create_collection()`: Manage collections
|
||||
- `delete_collection()`: Remove entire collections
|
||||
|
||||
### Vector Storage
|
||||
|
||||
- Collections are created with HNSW (Hierarchical Navigable Small World) index
|
||||
- Default distance metric: Cosine similarity
|
||||
- Default vector dimension: 384 (adjusts automatically based on embeddings)
|
||||
- Metadata is stored alongside vectors for filtering
|
||||
|
||||
## Advantages Over Other Vector Databases
|
||||
|
||||
### vs. ChromaDB
|
||||
- ✅ Better MySQL compatibility
|
||||
- ✅ Hybrid search capabilities (vector + full-text + SQL)
|
||||
- ✅ Production-grade distributed mode support
|
||||
- ✅ Lightweight embedded mode
|
||||
|
||||
### vs. Qdrant
|
||||
- ✅ SQL query support
|
||||
- ✅ MySQL ecosystem integration
|
||||
- ✅ Simpler deployment (no Docker required for embedded mode)
|
||||
- ✅ Multi-model data support (not just vectors)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Import Error
|
||||
|
||||
If you see: `ImportError: pyseekdb is not installed`
|
||||
|
||||
Solution:
|
||||
```bash
|
||||
pip install pyseekdb
|
||||
```
|
||||
|
||||
### Embedded Mode Error on macOS/Windows
|
||||
|
||||
**Error**:
|
||||
```
|
||||
RuntimeError: Embedded Client is not available because pylibseekdb is not available.
|
||||
Please install pylibseekdb (Linux only) or use RemoteServerClient (host/port) instead.
|
||||
```
|
||||
|
||||
**Cause**: `pylibseekdb` is only available on Linux platforms.
|
||||
|
||||
**Solution**: Use server mode instead:
|
||||
1. Deploy SeekDB on a Linux server or VM
|
||||
2. Configure LangBot to use server mode:
|
||||
```yaml
|
||||
vdb:
|
||||
use: seekdb
|
||||
seekdb:
|
||||
mode: server
|
||||
host: 'your-seekdb-server-ip'
|
||||
port: 2881
|
||||
database: 'langbot'
|
||||
user: 'root'
|
||||
password: ''
|
||||
```
|
||||
|
||||
**Alternative**: Use ChromaDB or Qdrant, which work on all platforms:
|
||||
```yaml
|
||||
vdb:
|
||||
use: chroma # or qdrant
|
||||
```
|
||||
|
||||
### Docker Container Fails on macOS
|
||||
|
||||
**Symptoms**:
|
||||
```bash
|
||||
docker run -d -p 2881:2881 oceanbase/seekdb:latest
|
||||
# Container exits immediately with code 30
|
||||
```
|
||||
|
||||
**Error in logs**:
|
||||
```
|
||||
[ERROR] Code: Agent.SeekDB.Not.Exists
|
||||
Message: initialize failed: init agent failed: SeekDB not exists in current directory.
|
||||
```
|
||||
|
||||
**Cause**: This is a known issue with SeekDB Docker containers on macOS. See [oceanbase/seekdb#36](https://github.com/oceanbase/seekdb/issues/36).
|
||||
|
||||
**Status**: Under investigation by OceanBase team.
|
||||
|
||||
**Workaround Options**:
|
||||
1. **Use alternatives**: ChromaDB or Qdrant work perfectly on macOS
|
||||
2. **Remote server**: Deploy SeekDB on a Linux server and connect remotely
|
||||
3. **Wait for fix**: Monitor the GitHub issue for updates
|
||||
|
||||
### Connection Error (Server Mode)
|
||||
|
||||
If SeekDB server is not reachable, check:
|
||||
1. Server is running: `ps aux | grep observer`
|
||||
2. Port is accessible: `nc -zv localhost 2881`
|
||||
3. Credentials are correct in config
|
||||
4. Firewall allows connections on port 2881
|
||||
|
||||
### Performance Issues
|
||||
|
||||
For large datasets:
|
||||
- Use server mode instead of embedded mode
|
||||
- Ensure adequate memory allocation
|
||||
- Consider using OceanBase distributed mode for very large scale
|
||||
- Adjust HNSW index parameters if needed
|
||||
|
||||
## Resources
|
||||
|
||||
- SeekDB GitHub: https://github.com/oceanbase/seekdb
|
||||
- pyseekdb SDK: https://github.com/oceanbase/pyseekdb
|
||||
- OceanBase Documentation: https://oceanbase.ai
|
||||
- LangBot Documentation: https://docs.langbot.app
|
||||
|
||||
## License
|
||||
|
||||
SeekDB is licensed under Apache License 2.0.
|
||||
@@ -1,10 +1,10 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.6.1"
|
||||
version = "4.6.5"
|
||||
description = "Easy-to-use global IM bot platform designed for LLM era"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
requires-python = ">=3.10.1,<4.0"
|
||||
requires-python = ">=3.11,<4.0"
|
||||
dependencies = [
|
||||
"aiocqhttp>=1.4.4",
|
||||
"aiofiles>=24.1.0",
|
||||
@@ -63,11 +63,14 @@ dependencies = [
|
||||
"langchain-text-splitters>=0.0.1",
|
||||
"chromadb>=0.4.24",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"langbot-plugin==0.2.0",
|
||||
"pyseekdb>=0.1.0",
|
||||
"langbot-plugin==0.2.4",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
"boto3>=1.35.0",
|
||||
"pymilvus>=2.6.4",
|
||||
"pgvector>=0.4.1",
|
||||
]
|
||||
keywords = [
|
||||
"bot",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Easy-to-use global IM bot platform designed for LLM era"""
|
||||
|
||||
__version__ = '4.6.1'
|
||||
__version__ = '4.6.5'
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import urllib.parse
|
||||
from typing import Callable
|
||||
import dingtalk_stream # type: ignore
|
||||
import websockets
|
||||
from .EchoHandler import EchoTextHandler
|
||||
from .dingtalkevent import DingTalkEvent
|
||||
import httpx
|
||||
@@ -36,6 +39,7 @@ class DingTalkClient:
|
||||
self.access_token_expiry_time = ''
|
||||
self.markdown_card = markdown_card
|
||||
self.logger = logger
|
||||
self._stopped = False # Flag to control the event loop
|
||||
|
||||
async def get_access_token(self):
|
||||
url = 'https://api.dingtalk.com/v1.0/oauth2/accessToken'
|
||||
@@ -170,6 +174,9 @@ class DingTalkClient:
|
||||
"""
|
||||
处理消息事件。
|
||||
"""
|
||||
# Skip message handling if stopped
|
||||
if self._stopped:
|
||||
return
|
||||
msg_type = event.conversation
|
||||
if msg_type in self._message_handlers:
|
||||
for handler in self._message_handlers[msg_type]:
|
||||
@@ -378,4 +385,70 @@ class DingTalkClient:
|
||||
|
||||
async def start(self):
|
||||
"""启动 WebSocket 连接,监听消息"""
|
||||
await self.client.start()
|
||||
self._stopped = False
|
||||
self.client.pre_start()
|
||||
|
||||
while not self._stopped:
|
||||
try:
|
||||
connection = self.client.open_connection()
|
||||
|
||||
if not connection:
|
||||
if self.logger:
|
||||
await self.logger.error('DingTalk: open connection failed')
|
||||
await asyncio.sleep(10)
|
||||
continue
|
||||
|
||||
uri = '%s?ticket=%s' % (connection['endpoint'], urllib.parse.quote_plus(connection['ticket']))
|
||||
async with websockets.connect(uri) as websocket:
|
||||
self.client.websocket = websocket
|
||||
keepalive_task = asyncio.create_task(self._keepalive(websocket))
|
||||
try:
|
||||
async for raw_message in websocket:
|
||||
if self._stopped:
|
||||
break
|
||||
json_message = json.loads(raw_message)
|
||||
asyncio.create_task(self.client.background_task(json_message))
|
||||
finally:
|
||||
keepalive_task.cancel()
|
||||
try:
|
||||
await keepalive_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except asyncio.CancelledError:
|
||||
# Properly exit when task is cancelled
|
||||
break
|
||||
except websockets.exceptions.ConnectionClosedError as e:
|
||||
if self._stopped:
|
||||
break
|
||||
if self.logger:
|
||||
await self.logger.error(f'DingTalk: connection closed, reconnecting... error={e}')
|
||||
await asyncio.sleep(5)
|
||||
continue
|
||||
except Exception as e:
|
||||
if self._stopped:
|
||||
break
|
||||
if self.logger:
|
||||
await self.logger.error(f'DingTalk: unknown exception, reconnecting... error={e}')
|
||||
await asyncio.sleep(3)
|
||||
continue
|
||||
|
||||
async def _keepalive(self, ws, ping_interval=60):
|
||||
"""Keep WebSocket connection alive"""
|
||||
while not self._stopped:
|
||||
await asyncio.sleep(ping_interval)
|
||||
try:
|
||||
await ws.ping()
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
break
|
||||
|
||||
async def stop(self):
|
||||
"""停止 WebSocket 连接"""
|
||||
self._stopped = True
|
||||
# Close WebSocket connection if exists
|
||||
if self.client.websocket:
|
||||
try:
|
||||
await self.client.websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
# Clear message handlers to prevent stale callbacks
|
||||
self._message_handlers = {'example': []}
|
||||
|
||||
@@ -394,7 +394,6 @@ class WecomBotClient:
|
||||
"""
|
||||
try:
|
||||
self.wxcpt = WXBizMsgCrypt(self.Token, self.EnCodingAESKey, '')
|
||||
await self.logger.info(f'{req.method} {req.url} {str(req.args)}')
|
||||
|
||||
if req.method == 'GET':
|
||||
return await self._handle_get_callback(req)
|
||||
@@ -458,32 +457,174 @@ class WecomBotClient:
|
||||
async def get_message(self, msg_json):
|
||||
message_data = {}
|
||||
|
||||
msg_type = msg_json.get('msgtype', '')
|
||||
if msg_type:
|
||||
message_data['msgtype'] = msg_type
|
||||
|
||||
if msg_json.get('chattype', '') == 'single':
|
||||
message_data['type'] = 'single'
|
||||
elif msg_json.get('chattype', '') == 'group':
|
||||
message_data['type'] = 'group'
|
||||
|
||||
if msg_json.get('msgtype') == 'text':
|
||||
max_inline_file_size = 5 * 1024 * 1024 # avoid decoding very large payloads by default
|
||||
|
||||
async def _safe_download(url: str):
|
||||
if not url:
|
||||
return None
|
||||
return await self.download_url_to_base64(url, self.EnCodingAESKey)
|
||||
|
||||
if msg_type == 'text':
|
||||
message_data['content'] = msg_json.get('text', {}).get('content')
|
||||
elif msg_json.get('msgtype') == 'image':
|
||||
elif msg_type == 'markdown':
|
||||
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
|
||||
'content', ''
|
||||
)
|
||||
elif msg_type == 'image':
|
||||
picurl = msg_json.get('image', {}).get('url', '')
|
||||
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
|
||||
message_data['picurl'] = base64
|
||||
elif msg_json.get('msgtype') == 'mixed':
|
||||
base64_data = await _safe_download(picurl)
|
||||
if base64_data:
|
||||
message_data['picurl'] = base64_data
|
||||
message_data['images'] = [base64_data]
|
||||
elif msg_type == 'voice':
|
||||
voice_info = msg_json.get('voice', {}) or {}
|
||||
download_url = voice_info.get('url')
|
||||
message_data['voice'] = {
|
||||
'url': download_url,
|
||||
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||
'filesize': voice_info.get('filesize') or voice_info.get('size'),
|
||||
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
|
||||
}
|
||||
# 企业微信智能转写文本(如果已有)直接复用,避免重复转写
|
||||
if voice_info.get('content'):
|
||||
message_data['content'] = voice_info.get('content')
|
||||
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
||||
voice_base64 = await _safe_download(download_url)
|
||||
if voice_base64:
|
||||
message_data['voice']['base64'] = voice_base64
|
||||
elif msg_type == 'video':
|
||||
video_info = msg_json.get('video', {}) or {}
|
||||
download_url = video_info.get('url')
|
||||
video_data = {
|
||||
'url': download_url,
|
||||
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
|
||||
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||
'filename': video_info.get('filename') or video_info.get('name'),
|
||||
}
|
||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
video_base64 = await _safe_download(download_url)
|
||||
if video_base64:
|
||||
video_data['base64'] = video_base64
|
||||
message_data['video'] = video_data
|
||||
elif msg_type == 'file':
|
||||
file_info = msg_json.get('file', {}) or {}
|
||||
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||
file_data = {
|
||||
'filename': file_info.get('filename') or file_info.get('name'),
|
||||
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||
'download_url': download_url,
|
||||
'extra': file_info,
|
||||
}
|
||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
file_base64 = await _safe_download(download_url)
|
||||
if file_base64:
|
||||
file_data['base64'] = file_base64
|
||||
message_data['file'] = file_data
|
||||
elif msg_type == 'link':
|
||||
message_data['link'] = msg_json.get('link', {})
|
||||
if not message_data.get('content'):
|
||||
title = message_data['link'].get('title', '')
|
||||
desc = message_data['link'].get('description') or message_data['link'].get('digest', '')
|
||||
message_data['content'] = '\n'.join(filter(None, [title, desc]))
|
||||
elif msg_type == 'mixed':
|
||||
items = msg_json.get('mixed', {}).get('msg_item', [])
|
||||
texts = []
|
||||
picurl = None
|
||||
images = []
|
||||
files = []
|
||||
voices = []
|
||||
videos = []
|
||||
links = []
|
||||
for item in items:
|
||||
if item.get('msgtype') == 'text':
|
||||
item_type = item.get('msgtype')
|
||||
if item_type == 'text':
|
||||
texts.append(item.get('text', {}).get('content', ''))
|
||||
elif item.get('msgtype') == 'image' and picurl is None:
|
||||
picurl = item.get('image', {}).get('url')
|
||||
elif item_type == 'image':
|
||||
img_url = item.get('image', {}).get('url')
|
||||
base64_data = await _safe_download(img_url)
|
||||
if base64_data:
|
||||
images.append(base64_data)
|
||||
elif item_type == 'file':
|
||||
file_info = item.get('file', {}) or {}
|
||||
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||
file_data = {
|
||||
'filename': file_info.get('filename') or file_info.get('name'),
|
||||
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
|
||||
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
|
||||
'download_url': download_url,
|
||||
'extra': file_info,
|
||||
}
|
||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
file_base64 = await _safe_download(download_url)
|
||||
if file_base64:
|
||||
file_data['base64'] = file_base64
|
||||
files.append(file_data)
|
||||
elif item_type == 'voice':
|
||||
voice_info = item.get('voice', {}) or {}
|
||||
download_url = voice_info.get('url')
|
||||
voice_data = {
|
||||
'url': download_url,
|
||||
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||
'filesize': voice_info.get('filesize') or voice_info.get('size'),
|
||||
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
|
||||
}
|
||||
if voice_info.get('content'):
|
||||
texts.append(voice_info.get('content'))
|
||||
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
voice_base64 = await _safe_download(download_url)
|
||||
if voice_base64:
|
||||
voice_data['base64'] = voice_base64
|
||||
voices.append(voice_data)
|
||||
elif item_type == 'video':
|
||||
video_info = item.get('video', {}) or {}
|
||||
download_url = video_info.get('url')
|
||||
video_data = {
|
||||
'url': download_url,
|
||||
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
|
||||
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
|
||||
'filename': video_info.get('filename') or video_info.get('name'),
|
||||
}
|
||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
video_base64 = await _safe_download(download_url)
|
||||
if video_base64:
|
||||
video_data['base64'] = video_base64
|
||||
videos.append(video_data)
|
||||
elif item_type == 'link':
|
||||
links.append(item.get('link', {}))
|
||||
|
||||
if texts:
|
||||
message_data['content'] = ''.join(texts) # 拼接所有 text
|
||||
if picurl:
|
||||
base64 = await self.download_url_to_base64(picurl, self.EnCodingAESKey)
|
||||
message_data['picurl'] = base64 # 只保留第一个 image
|
||||
message_data['content'] = ' '.join(texts) # 拼接所有 text
|
||||
if images:
|
||||
message_data['images'] = images
|
||||
message_data['picurl'] = images[0] # 只保留第一个 image
|
||||
if files:
|
||||
message_data['files'] = files
|
||||
message_data['file'] = files[0]
|
||||
if voices:
|
||||
message_data['voices'] = voices
|
||||
message_data['voice'] = voices[0]
|
||||
if videos:
|
||||
message_data['videos'] = videos
|
||||
message_data['video'] = videos[0]
|
||||
if links:
|
||||
message_data['link'] = links[0]
|
||||
if items:
|
||||
message_data['attachments'] = items
|
||||
else:
|
||||
message_data['raw_msg'] = msg_json
|
||||
|
||||
# Extract user information
|
||||
from_info = msg_json.get('from', {})
|
||||
|
||||
@@ -17,6 +17,13 @@ class WecomBotEvent(dict):
|
||||
"""
|
||||
return self.get('type', '')
|
||||
|
||||
@property
|
||||
def msgtype(self) -> str:
|
||||
"""
|
||||
消息 msgtype
|
||||
"""
|
||||
return self.get('msgtype', '')
|
||||
|
||||
@property
|
||||
def userid(self) -> str:
|
||||
"""
|
||||
@@ -52,6 +59,55 @@ class WecomBotEvent(dict):
|
||||
"""
|
||||
return self.get('picurl', '')
|
||||
|
||||
@property
|
||||
def images(self):
|
||||
"""
|
||||
图片列表(兼容 mixed)
|
||||
"""
|
||||
return self.get('images', [])
|
||||
|
||||
@property
|
||||
def file(self):
|
||||
"""
|
||||
文件信息
|
||||
"""
|
||||
return self.get('file', {})
|
||||
|
||||
@property
|
||||
def voice(self):
|
||||
"""
|
||||
语音信息
|
||||
"""
|
||||
return self.get('voice', {})
|
||||
|
||||
@property
|
||||
def video(self):
|
||||
"""
|
||||
视频信息
|
||||
"""
|
||||
return self.get('video', {})
|
||||
|
||||
@property
|
||||
def link(self):
|
||||
"""
|
||||
链接消息信息
|
||||
"""
|
||||
return self.get('link', {})
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
"""
|
||||
位置信息
|
||||
"""
|
||||
return self.get('location', {})
|
||||
|
||||
@property
|
||||
def attachments(self):
|
||||
"""
|
||||
原始 mixed 中的附件项
|
||||
"""
|
||||
return self.get('attachments', [])
|
||||
|
||||
@property
|
||||
def chatid(self) -> str:
|
||||
"""
|
||||
|
||||
@@ -139,6 +139,58 @@ class WecomClient:
|
||||
await self.logger.error(f'发送图片失败:{data}')
|
||||
raise Exception('Failed to send image: ' + str(data))
|
||||
|
||||
async def send_voice(self, user_id: str, agent_id: int, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
url = self.base_url + '/message/send?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
params = {
|
||||
'touser': user_id,
|
||||
'msgtype': 'voice',
|
||||
'agentid': agent_id,
|
||||
'voice': {
|
||||
'media_id': media_id,
|
||||
},
|
||||
'safe': 0,
|
||||
'enable_id_trans': 0,
|
||||
'enable_duplicate_check': 0,
|
||||
'duplicate_check_interval': 1800,
|
||||
}
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_voice(user_id, agent_id, media_id)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送语音失败:{data}')
|
||||
raise Exception('Failed to send voice: ' + str(data))
|
||||
|
||||
async def send_file(self, user_id: str, agent_id: int, media_id: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
url = self.base_url + '/message/send?access_token=' + self.access_token
|
||||
async with httpx.AsyncClient() as client:
|
||||
params = {
|
||||
'touser': user_id,
|
||||
'msgtype': 'file',
|
||||
'agentid': agent_id,
|
||||
'file': {
|
||||
'media_id': media_id,
|
||||
},
|
||||
'safe': 0,
|
||||
'enable_id_trans': 0,
|
||||
'enable_duplicate_check': 0,
|
||||
'duplicate_check_interval': 1800,
|
||||
}
|
||||
response = await client.post(url, json=params)
|
||||
data = response.json()
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
return await self.send_file(user_id, agent_id, media_id)
|
||||
if data['errcode'] != 0:
|
||||
await self.logger.error(f'发送文件失败:{data}')
|
||||
raise Exception('Failed to send file: ' + str(data))
|
||||
|
||||
async def send_private_msg(self, user_id: str, agent_id: int, content: str):
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
@@ -287,7 +339,7 @@ class WecomClient:
|
||||
return ext
|
||||
return 'jpg' # 默认返回jpg
|
||||
|
||||
async def upload_to_work(self, image: platform_message.Image):
|
||||
async def upload_image_to_work(self, image: platform_message.Image):
|
||||
"""
|
||||
获取 media_id
|
||||
"""
|
||||
@@ -304,7 +356,7 @@ class WecomClient:
|
||||
file_bytes = await f.read()
|
||||
file_name = image.path.split('/')[-1]
|
||||
elif image.url:
|
||||
file_bytes = await self.download_image_to_bytes(image.url)
|
||||
file_bytes = await self.download_media_to_bytes(image.url)
|
||||
file_name = image.url.split('/')[-1]
|
||||
elif image.base64:
|
||||
try:
|
||||
@@ -339,7 +391,7 @@ class WecomClient:
|
||||
data = response.json()
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
media_id = await self.upload_to_work(image)
|
||||
media_id = await self.upload_image_to_work(image)
|
||||
if data.get('errcode', 0) != 0:
|
||||
await self.logger.error(f'上传图片失败:{data}')
|
||||
raise Exception('failed to upload file')
|
||||
@@ -347,13 +399,128 @@ class WecomClient:
|
||||
media_id = data.get('media_id')
|
||||
return media_id
|
||||
|
||||
async def download_image_to_bytes(self, url: str) -> bytes:
|
||||
async def upload_voice_to_work(self, voice: platform_message.Voice):
|
||||
"""
|
||||
上传语音文件到企业微信
|
||||
"""
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
|
||||
file_bytes = None
|
||||
file_name = 'voice.mp3'
|
||||
|
||||
if voice.path:
|
||||
async with aiofiles.open(voice.path, 'rb') as f:
|
||||
file_bytes = await f.read()
|
||||
file_name = voice.path.split('/')[-1]
|
||||
elif voice.url:
|
||||
file_bytes = await self.download_media_to_bytes(voice.url)
|
||||
file_name = voice.url.split('/')[-1]
|
||||
elif voice.base64:
|
||||
try:
|
||||
base64_data = voice.base64
|
||||
if ',' in base64_data:
|
||||
base64_data = base64_data.split(',', 1)[1]
|
||||
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
|
||||
padded_base64 = base64_data + '=' * padding
|
||||
file_bytes = base64.b64decode(padded_base64)
|
||||
except binascii.Error as e:
|
||||
raise ValueError(f'Invalid base64 string: {str(e)}')
|
||||
else:
|
||||
await self.logger.error('Voice对象出错')
|
||||
raise ValueError('voice对象出错')
|
||||
|
||||
boundary = '-------------------------acebdf13572468'
|
||||
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
|
||||
body = (
|
||||
(
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n'
|
||||
f'Content-Type: application/octet-stream\r\n\r\n'
|
||||
).encode('utf-8')
|
||||
+ file_bytes
|
||||
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
|
||||
)
|
||||
|
||||
# print(body)
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, content=body)
|
||||
data = response.json()
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
media_id = await self.upload_voice_to_work(voice)
|
||||
if data.get('errcode', 0) != 0:
|
||||
await self.logger.error(f'上传语音文件失败:{data}')
|
||||
raise Exception('failed to upload file')
|
||||
media_id = data.get('media_id')
|
||||
return media_id
|
||||
|
||||
async def upload_file_to_work(self, file: platform_message.File):
|
||||
"""
|
||||
上传文件到企业微信
|
||||
"""
|
||||
if not await self.check_access_token():
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
url = self.base_url + '/media/upload?access_token=' + self.access_token + '&type=file'
|
||||
file_bytes = None
|
||||
file_name = 'file.txt'
|
||||
if file.path:
|
||||
async with aiofiles.open(file.path, 'rb') as f:
|
||||
file_bytes = await f.read()
|
||||
file_name = file.path.split('/')[-1]
|
||||
elif file.url:
|
||||
file_bytes = await self.download_media_to_bytes(file.url)
|
||||
file_name = file.url.split('/')[-1]
|
||||
elif file.base64:
|
||||
try:
|
||||
base64_data = file.base64
|
||||
if ',' in base64_data:
|
||||
base64_data = base64_data.split(',', 1)[1]
|
||||
padding = 4 - (len(base64_data) % 4) if len(base64_data) % 4 else 0
|
||||
padded_base64 = base64_data + '=' * padding
|
||||
file_bytes = base64.b64decode(padded_base64)
|
||||
except binascii.Error as e:
|
||||
raise ValueError(f'Invalid base64 string: {str(e)}')
|
||||
else:
|
||||
await self.logger.error('File对象出错')
|
||||
raise ValueError('file对象出错')
|
||||
boundary = '-------------------------acebdf13572468'
|
||||
headers = {'Content-Type': f'multipart/form-data; boundary={boundary}'}
|
||||
body = (
|
||||
(
|
||||
f'--{boundary}\r\n'
|
||||
f'Content-Disposition: form-data; name="media"; filename="{file_name}"; filelength={len(file_bytes)}\r\n'
|
||||
f'Content-Type: application/octet-stream\r\n\r\n'
|
||||
).encode('utf-8')
|
||||
+ file_bytes
|
||||
+ f'\r\n--{boundary}--\r\n'.encode('utf-8')
|
||||
)
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, headers=headers, content=body)
|
||||
data = response.json()
|
||||
if data['errcode'] == 40014 or data['errcode'] == 42001:
|
||||
self.access_token = await self.get_access_token(self.secret)
|
||||
media_id = await self.upload_file_to_work(file)
|
||||
if data.get('errcode', 0) != 0:
|
||||
await self.logger.error(f'上传文件失败:{data}')
|
||||
raise Exception('failed to upload file')
|
||||
media_id = data.get('media_id')
|
||||
return media_id
|
||||
|
||||
async def download_media_to_bytes(self, url: str) -> bytes:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url)
|
||||
response.raise_for_status()
|
||||
return response.content
|
||||
|
||||
# 进行media_id的获取
|
||||
async def get_media_id(self, image: platform_message.Image):
|
||||
media_id = await self.upload_to_work(image=image)
|
||||
async def get_media_id(self, media: platform_message.Image | platform_message.Voice | platform_message.File):
|
||||
if isinstance(media, platform_message.Image):
|
||||
media_id = await self.upload_image_to_work(image=media)
|
||||
elif isinstance(media, platform_message.Voice):
|
||||
media_id = await self.upload_voice_to_work(voice=media)
|
||||
elif isinstance(media, platform_message.File):
|
||||
media_id = await self.upload_file_to_work(file=media)
|
||||
else:
|
||||
raise ValueError('Unsupported media type')
|
||||
return media_id
|
||||
|
||||
@@ -49,6 +49,14 @@ class PipelinesRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success()
|
||||
|
||||
@self.route('/<pipeline_uuid>/copy', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _(pipeline_uuid: str) -> str:
|
||||
try:
|
||||
new_uuid = await self.ap.pipeline_service.copy_pipeline(pipeline_uuid)
|
||||
return self.success(data={'uuid': new_uuid})
|
||||
except ValueError as e:
|
||||
return self.http_status(404, -1, str(e))
|
||||
|
||||
@self.route(
|
||||
'/<pipeline_uuid>/extensions', methods=['GET', 'PUT'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||
)
|
||||
|
||||
@@ -23,6 +23,9 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
if 'cloud_service_url' in self.ap.instance_config.data.get('plugin', {})
|
||||
else 'https://space.langbot.app'
|
||||
),
|
||||
'allow_change_password': self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_change_password', True
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -70,6 +70,13 @@ class UserRouterGroup(group.RouterGroup):
|
||||
|
||||
@self.route('/change-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(user_email: str) -> str:
|
||||
# Check if password change is allowed
|
||||
allow_change_password = self.ap.instance_config.data.get('system', {}).get(
|
||||
'allow_change_password', True
|
||||
)
|
||||
if not allow_change_password:
|
||||
return self.http_status(403, -1, 'Password change is disabled')
|
||||
|
||||
json_data = await quart.request.json
|
||||
|
||||
current_password = json_data['current_password']
|
||||
|
||||
49
src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py
Normal file
49
src/langbot/pkg/api/http/controller/groups/webhook_mgmt.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('webhook_mgmt', '/api/v1/webhooks')
|
||||
class WebhookManagementRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET', 'POST'])
|
||||
async def _() -> str:
|
||||
if quart.request.method == 'GET':
|
||||
webhooks = await self.ap.webhook_service.get_webhooks()
|
||||
return self.success(data={'webhooks': webhooks})
|
||||
elif quart.request.method == 'POST':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name', '')
|
||||
url = json_data.get('url', '')
|
||||
description = json_data.get('description', '')
|
||||
enabled = json_data.get('enabled', True)
|
||||
|
||||
if not name:
|
||||
return self.http_status(400, -1, 'Name is required')
|
||||
if not url:
|
||||
return self.http_status(400, -1, 'URL is required')
|
||||
|
||||
webhook = await self.ap.webhook_service.create_webhook(name, url, description, enabled)
|
||||
return self.success(data={'webhook': webhook})
|
||||
|
||||
@self.route('/<int:webhook_id>', methods=['GET', 'PUT', 'DELETE'])
|
||||
async def _(webhook_id: int) -> str:
|
||||
if quart.request.method == 'GET':
|
||||
webhook = await self.ap.webhook_service.get_webhook(webhook_id)
|
||||
if webhook is None:
|
||||
return self.http_status(404, -1, 'Webhook not found')
|
||||
return self.success(data={'webhook': webhook})
|
||||
|
||||
elif quart.request.method == 'PUT':
|
||||
json_data = await quart.request.json
|
||||
name = json_data.get('name')
|
||||
url = json_data.get('url')
|
||||
description = json_data.get('description')
|
||||
enabled = json_data.get('enabled')
|
||||
|
||||
await self.ap.webhook_service.update_webhook(webhook_id, name, url, description, enabled)
|
||||
return self.success()
|
||||
|
||||
elif quart.request.method == 'DELETE':
|
||||
await self.ap.webhook_service.delete_webhook(webhook_id)
|
||||
return self.success()
|
||||
@@ -92,7 +92,11 @@ class HTTPController:
|
||||
|
||||
@self.quart_app.route('/')
|
||||
async def index():
|
||||
return await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
||||
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
|
||||
@self.quart_app.route('/<path:path>')
|
||||
async def static_file(path: str):
|
||||
|
||||
@@ -59,7 +59,7 @@ class BotService:
|
||||
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
|
||||
|
||||
# Webhook URL for unified webhook adapters (independent of bot running state)
|
||||
if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack', 'wecomcs', 'LINE']:
|
||||
if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack', 'wecomcs', 'LINE', 'lark']:
|
||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||
webhook_url = f'/bots/{bot_uuid}'
|
||||
adapter_runtime_values['webhook_url'] = webhook_url
|
||||
|
||||
@@ -151,6 +151,52 @@ class PipelineService:
|
||||
)
|
||||
await self.ap.pipeline_mgr.remove_pipeline(pipeline_uuid)
|
||||
|
||||
async def copy_pipeline(self, pipeline_uuid: str) -> str:
|
||||
"""Copy a pipeline with all its configurations"""
|
||||
# Get the original pipeline
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
||||
persistence_pipeline.LegacyPipeline.uuid == pipeline_uuid
|
||||
)
|
||||
)
|
||||
|
||||
original_pipeline = result.first()
|
||||
if original_pipeline is None:
|
||||
raise ValueError(f'Pipeline {pipeline_uuid} not found')
|
||||
|
||||
# Create new pipeline data
|
||||
new_uuid = str(uuid.uuid4())
|
||||
new_pipeline_data = {
|
||||
'uuid': new_uuid,
|
||||
'name': f'{original_pipeline.name} (Copy)',
|
||||
'description': original_pipeline.description,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
'stages': original_pipeline.stages.copy() if original_pipeline.stages else default_stage_order.copy(),
|
||||
'config': original_pipeline.config.copy() if original_pipeline.config else {},
|
||||
'is_default': False,
|
||||
'extensions_preferences': (
|
||||
original_pipeline.extensions_preferences.copy()
|
||||
if original_pipeline.extensions_preferences
|
||||
else {
|
||||
'enable_all_plugins': True,
|
||||
'enable_all_mcp_servers': True,
|
||||
'plugins': [],
|
||||
'mcp_servers': [],
|
||||
}
|
||||
),
|
||||
}
|
||||
|
||||
# Insert the new pipeline
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_pipeline.LegacyPipeline).values(**new_pipeline_data)
|
||||
)
|
||||
|
||||
# Load the new pipeline
|
||||
pipeline = await self.get_pipeline(new_uuid)
|
||||
await self.ap.pipeline_mgr.load_pipeline(pipeline)
|
||||
|
||||
return new_uuid
|
||||
|
||||
async def update_pipeline_extensions(
|
||||
self,
|
||||
pipeline_uuid: str,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import logging.handlers
|
||||
import sys
|
||||
import time
|
||||
|
||||
@@ -15,6 +16,10 @@ log_colors_config = {
|
||||
'CRITICAL': 'cyan',
|
||||
}
|
||||
|
||||
# Log rotation configuration to prevent unbounded log file growth
|
||||
LOG_FILE_MAX_BYTES = 10 * 1024 * 1024 # 10MB per file
|
||||
LOG_FILE_BACKUP_COUNT = 5 # Keep 5 backup files (total ~50MB max)
|
||||
|
||||
|
||||
async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.Logger:
|
||||
# Remove all existing loggers
|
||||
@@ -43,9 +48,17 @@ async def init_logging(extra_handlers: list[logging.Handler] = None) -> logging.
|
||||
# stream_handler.setFormatter(color_formatter)
|
||||
stream_handler.stream = open(sys.stdout.fileno(), mode='w', encoding='utf-8', buffering=1)
|
||||
|
||||
# Use RotatingFileHandler to prevent unbounded log file growth
|
||||
rotating_file_handler = logging.handlers.RotatingFileHandler(
|
||||
log_file_name,
|
||||
encoding='utf-8',
|
||||
maxBytes=LOG_FILE_MAX_BYTES,
|
||||
backupCount=LOG_FILE_BACKUP_COUNT,
|
||||
)
|
||||
|
||||
log_handlers: list[logging.Handler] = [
|
||||
stream_handler,
|
||||
logging.FileHandler(log_file_name, encoding='utf-8'),
|
||||
rotating_file_handler,
|
||||
]
|
||||
log_handlers += extra_handlers if extra_handlers is not None else []
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(2)
|
||||
@@ -11,30 +10,45 @@ class DBMigrateCombineQuoteMsgConfig(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Ensure 'trigger' exists
|
||||
if 'trigger' not in config:
|
||||
config['trigger'] = {}
|
||||
|
||||
# Ensure 'misc' exists in 'trigger'
|
||||
if 'misc' not in config['trigger']:
|
||||
config['trigger']['misc'] = {}
|
||||
|
||||
# Add 'combine-quote-message' if not exists
|
||||
if 'combine-quote-message' not in config['trigger']['misc']:
|
||||
config['trigger']['misc']['combine-quote-message'] = False
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values(
|
||||
{
|
||||
'config': config,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
}
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(3)
|
||||
@@ -11,14 +10,23 @@ class DBMigrateN8nConfig(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Ensure 'ai' exists
|
||||
if 'ai' not in config:
|
||||
config['ai'] = {}
|
||||
|
||||
# Add 'n8n-service-api' if not exists
|
||||
if 'n8n-service-api' not in config['ai']:
|
||||
config['ai']['n8n-service-api'] = {
|
||||
'webhook-url': 'http://your-n8n-webhook-url',
|
||||
@@ -33,16 +41,21 @@ class DBMigrateN8nConfig(migration.DBMigration):
|
||||
'output-key': 'response',
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values(
|
||||
{
|
||||
'config': config,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
}
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(4)
|
||||
@@ -11,27 +10,43 @@ class DBMigrateRAGKBUUID(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""升级"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Ensure nested structure exists
|
||||
if 'ai' not in config:
|
||||
config['ai'] = {}
|
||||
if 'local-agent' not in config['ai']:
|
||||
config['ai']['local-agent'] = {}
|
||||
|
||||
# Add 'knowledge-base' if not exists
|
||||
if 'knowledge-base' not in config['ai']['local-agent']:
|
||||
config['ai']['local-agent']['knowledge-base'] = ''
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values(
|
||||
{
|
||||
'config': config,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
}
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""降级"""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(5)
|
||||
@@ -11,27 +10,43 @@ class DBMigratePipelineRemoveCotConfig(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Ensure nested structure exists
|
||||
if 'output' not in config:
|
||||
config['output'] = {}
|
||||
if 'misc' not in config['output']:
|
||||
config['output']['misc'] = {}
|
||||
|
||||
# Add 'remove-think' if not exists
|
||||
if 'remove-think' not in config['output']['misc']:
|
||||
config['output']['misc']['remove-think'] = False
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values(
|
||||
{
|
||||
'config': config,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
}
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(6)
|
||||
@@ -11,14 +10,23 @@ class DBMigrateLangflowApiConfig(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Ensure 'ai' exists
|
||||
if 'ai' not in config:
|
||||
config['ai'] = {}
|
||||
|
||||
# Add 'langflow-api' if not exists
|
||||
if 'langflow-api' not in config['ai']:
|
||||
config['ai']['langflow-api'] = {
|
||||
'base-url': 'http://localhost:7860',
|
||||
@@ -29,16 +37,21 @@ class DBMigrateLangflowApiConfig(migration.DBMigration):
|
||||
'tweaks': '{}',
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values(
|
||||
{
|
||||
'config': config,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
}
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(10)
|
||||
@@ -11,16 +10,20 @@ class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Convert knowledge-base from string to array
|
||||
if 'local-agent' in config['ai']:
|
||||
if 'ai' in config and 'local-agent' in config['ai']:
|
||||
current_kb = config['ai']['local-agent'].get('knowledge-base', '')
|
||||
|
||||
# If it's already a list, skip
|
||||
@@ -37,29 +40,38 @@ class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
|
||||
if 'knowledge-base' in config['ai']['local-agent']:
|
||||
del config['ai']['local-agent']['knowledge-base']
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values(
|
||||
{
|
||||
'config': config,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
}
|
||||
)
|
||||
)
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Convert knowledge-bases from array back to string
|
||||
if 'local-agent' in config['ai']:
|
||||
if 'ai' in config and 'local-agent' in config['ai']:
|
||||
current_kbs = config['ai']['local-agent'].get('knowledge-bases', [])
|
||||
|
||||
# If it's already a string, skip
|
||||
@@ -76,13 +88,18 @@ class DBMigratePipelineMultiKnowledgeBase(migration.DBMigration):
|
||||
if 'knowledge-bases' in config['ai']['local-agent']:
|
||||
del config['ai']['local-agent']['knowledge-bases']
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values(
|
||||
{
|
||||
'config': config,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
}
|
||||
)
|
||||
)
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(11)
|
||||
@@ -11,29 +10,45 @@ class DBMigrateDifyApiConfig(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
config = serialized_pipeline['config']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
|
||||
# Ensure nested structure exists
|
||||
if 'ai' not in config:
|
||||
config['ai'] = {}
|
||||
if 'dify-service-api' not in config['ai']:
|
||||
config['ai']['dify-service-api'] = {}
|
||||
|
||||
# Add 'base-prompt' if not exists
|
||||
if 'base-prompt' not in config['ai']['dify-service-api']:
|
||||
config['ai']['dify-service-api']['base-prompt'] = (
|
||||
'When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image.',
|
||||
)
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values(
|
||||
{
|
||||
'config': config,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
}
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from .. import migration
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ...entity.persistence import pipeline as persistence_pipeline
|
||||
import json
|
||||
|
||||
|
||||
@migration.migration_class(12)
|
||||
@@ -11,14 +10,25 @@ class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
# read all pipelines
|
||||
pipelines = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_pipeline.LegacyPipeline))
|
||||
# Read all pipelines using raw SQL
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT uuid, extensions_preferences FROM legacy_pipelines')
|
||||
)
|
||||
pipelines = result.fetchall()
|
||||
|
||||
for pipeline in pipelines:
|
||||
serialized_pipeline = self.ap.persistence_mgr.serialize_model(persistence_pipeline.LegacyPipeline, pipeline)
|
||||
current_version = self.ap.ver_mgr.get_current_version()
|
||||
|
||||
extensions_preferences = serialized_pipeline['extensions_preferences']
|
||||
for pipeline_row in pipelines:
|
||||
uuid = pipeline_row[0]
|
||||
extensions_preferences = (
|
||||
json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||
)
|
||||
|
||||
# Ensure extensions_preferences is a dict
|
||||
if extensions_preferences is None:
|
||||
extensions_preferences = {}
|
||||
|
||||
# Add 'enable_all_plugins' if not exists
|
||||
if 'enable_all_plugins' not in extensions_preferences:
|
||||
if 'plugins' in extensions_preferences:
|
||||
extensions_preferences['enable_all_plugins'] = False
|
||||
@@ -26,6 +36,7 @@ class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
|
||||
extensions_preferences['enable_all_plugins'] = True
|
||||
extensions_preferences['plugins'] = []
|
||||
|
||||
# Add 'enable_all_mcp_servers' if not exists
|
||||
if 'enable_all_mcp_servers' not in extensions_preferences:
|
||||
if 'mcp_servers' in extensions_preferences:
|
||||
extensions_preferences['enable_all_mcp_servers'] = False
|
||||
@@ -33,14 +44,29 @@ class DBMigratePipelineExtensionsEnableAll(migration.DBMigration):
|
||||
extensions_preferences['enable_all_mcp_servers'] = True
|
||||
extensions_preferences['mcp_servers'] = []
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||
.where(persistence_pipeline.LegacyPipeline.uuid == serialized_pipeline['uuid'])
|
||||
.values(
|
||||
extensions_preferences=extensions_preferences,
|
||||
for_version=self.ap.ver_mgr.get_current_version(),
|
||||
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{
|
||||
'extensions_preferences': json.dumps(extensions_preferences),
|
||||
'for_version': current_version,
|
||||
'uuid': uuid,
|
||||
},
|
||||
)
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE legacy_pipelines SET extensions_preferences = :extensions_preferences, for_version = :for_version WHERE uuid = :uuid'
|
||||
),
|
||||
{
|
||||
'extensions_preferences': json.dumps(extensions_preferences),
|
||||
'for_version': current_version,
|
||||
'uuid': uuid,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -33,11 +33,14 @@ class Controller:
|
||||
|
||||
for query in queries:
|
||||
session = await self.ap.sess_mgr.get_session(query)
|
||||
self.ap.logger.debug(f'Checking query {query} session {session}')
|
||||
# Debug logging removed from tight loop to prevent excessive log generation
|
||||
# that can cause memory overflow in high-traffic scenarios
|
||||
|
||||
if not session._semaphore.locked():
|
||||
selected_query = query
|
||||
await session._semaphore.acquire()
|
||||
# Only log when actually selecting a query
|
||||
self.ap.logger.debug(f'Selected query {query.query_id} for processing')
|
||||
|
||||
break
|
||||
|
||||
|
||||
@@ -237,6 +237,7 @@ class RuntimePipeline:
|
||||
launcher_type=query.launcher_type.value,
|
||||
launcher_id=query.launcher_id,
|
||||
sender_id=query.sender_id,
|
||||
message_event=query.message_event,
|
||||
message_chain=query.message_chain,
|
||||
)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from langbot_plugin.api.entities.builtin.provider import message as provider_mes
|
||||
import langbot_plugin.api.entities.events as events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
|
||||
|
||||
@stage.stage_class('PreProcessor')
|
||||
@@ -74,12 +75,26 @@ class PreProcessor(stage.PipelineStage):
|
||||
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
||||
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
|
||||
|
||||
sender_name = ''
|
||||
|
||||
if isinstance(query.message_event, platform_events.GroupMessage):
|
||||
sender_name = query.message_event.sender.member_name
|
||||
elif isinstance(query.message_event, platform_events.FriendMessage):
|
||||
sender_name = query.message_event.sender.nickname
|
||||
|
||||
variables = {
|
||||
'launcher_type': query.session.launcher_type.value,
|
||||
'launcher_id': query.session.launcher_id,
|
||||
'sender_id': query.sender_id,
|
||||
'session_id': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
'conversation_id': conversation.uuid,
|
||||
'msg_create_time': (
|
||||
int(query.message_event.time) if query.message_event.time else int(datetime.datetime.now().timestamp())
|
||||
),
|
||||
'group_name': query.message_event.group.name
|
||||
if isinstance(query.message_event, platform_events.GroupMessage)
|
||||
else '',
|
||||
'sender_name': sender_name,
|
||||
}
|
||||
query.variables.update(variables)
|
||||
|
||||
@@ -111,6 +126,12 @@ class PreProcessor(stage.PipelineStage):
|
||||
):
|
||||
if me.base64 is not None:
|
||||
content_list.append(provider_message.ContentElement.from_image_base64(me.base64))
|
||||
elif isinstance(me, platform_message.Voice):
|
||||
# 转成文件链接,让下游 runner 上传到目标模型
|
||||
if me.base64:
|
||||
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, 'voice.silk'))
|
||||
elif me.url:
|
||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
||||
elif isinstance(me, platform_message.File):
|
||||
# if me.url is not None:
|
||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||
|
||||
@@ -14,6 +14,7 @@ from ....utils import importutil
|
||||
from ....provider import runners
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
|
||||
importutil.import_modules_in_pkg(runners)
|
||||
@@ -40,6 +41,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
launcher_id=query.launcher_id,
|
||||
sender_id=query.sender_id,
|
||||
text_message=str(query.message_chain),
|
||||
message_event=query.message_event,
|
||||
message_chain=query.message_chain,
|
||||
query=query,
|
||||
)
|
||||
@@ -60,8 +62,14 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
||||
else:
|
||||
if event_ctx.event.user_message_alter is not None:
|
||||
# if isinstance(event_ctx.event, str): # 现在暂时不考虑多模态alter
|
||||
query.user_message.content = event_ctx.event.user_message_alter
|
||||
if isinstance(event_ctx.event.user_message_alter, list):
|
||||
query.user_message.content = event_ctx.event.user_message_alter
|
||||
elif isinstance(event_ctx.event.user_message_alter, str):
|
||||
query.user_message.content = [
|
||||
provider_message.ContentElement.from_text(event_ctx.event.user_message_alter)
|
||||
]
|
||||
elif isinstance(event_ctx.event.user_message_alter, provider_message.ContentElement):
|
||||
query.user_message.content = [event_ctx.event.user_message_alter]
|
||||
|
||||
text_length = 0
|
||||
try:
|
||||
@@ -75,9 +83,10 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
runner = r(self.ap, query.pipeline_config)
|
||||
break
|
||||
else:
|
||||
raise ValueError(f'未找到请求运行器: {query.pipeline_config["ai"]["runner"]["runner"]}')
|
||||
raise ValueError(f'Request Runner not found: {query.pipeline_config["ai"]["runner"]["runner"]}')
|
||||
if is_stream:
|
||||
resp_message_id = uuid.uuid4()
|
||||
chunk_count = 0 # Track streaming chunks to reduce excessive logging
|
||||
|
||||
async for result in runner.run(query):
|
||||
result.resp_message_id = str(resp_message_id)
|
||||
@@ -90,18 +99,37 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
await query.adapter.create_message_card(str(resp_message_id), query.message_event)
|
||||
is_create_card = True
|
||||
query.resp_messages.append(result)
|
||||
self.ap.logger.info(f'对话({query.query_id})流式响应: {self.cut_str(result.readable_str())}')
|
||||
|
||||
chunk_count += 1
|
||||
# Only log every 10th chunk to reduce excessive logging during streaming
|
||||
# This prevents memory overflow from thousands of log entries per conversation
|
||||
# First chunk uses INFO level to confirm connection establishment
|
||||
if chunk_count == 1:
|
||||
self.ap.logger.info(
|
||||
f'Conversation({query.query_id}) Streaming started: {self.cut_str(result.readable_str())}'
|
||||
)
|
||||
elif chunk_count % 10 == 0:
|
||||
self.ap.logger.debug(
|
||||
f'Conversation({query.query_id}) Streaming chunk {chunk_count}: {self.cut_str(result.readable_str())}'
|
||||
)
|
||||
|
||||
if result.content is not None:
|
||||
text_length += len(result.content)
|
||||
|
||||
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
|
||||
# Log final summary after streaming completes
|
||||
self.ap.logger.info(
|
||||
f'Conversation({query.query_id}) Streaming completed: {chunk_count} chunks, {text_length} chars'
|
||||
)
|
||||
|
||||
else:
|
||||
async for result in runner.run(query):
|
||||
query.resp_messages.append(result)
|
||||
|
||||
self.ap.logger.info(f'对话({query.query_id})响应: {self.cut_str(result.readable_str())}')
|
||||
self.ap.logger.info(
|
||||
f'Conversation({query.query_id}) Response: {self.cut_str(result.readable_str())}'
|
||||
)
|
||||
|
||||
if result.content is not None:
|
||||
text_length += len(result.content)
|
||||
@@ -112,7 +140,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
|
||||
query.session.using_conversation.messages.extend(query.resp_messages)
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'对话({query.query_id})请求失败: {type(e).__name__} {str(e)}')
|
||||
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {type(e).__name__} {str(e)}')
|
||||
traceback.print_exc()
|
||||
|
||||
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']
|
||||
|
||||
@@ -31,4 +31,8 @@ class AtBotRule(rule_model.GroupRespondRule):
|
||||
remove_at(message_chain)
|
||||
remove_at(message_chain) # 回复消息时会at两次,检查并删除重复的
|
||||
|
||||
should_respond_at = rule_dict.get('at', None)
|
||||
if should_respond_at is not None:
|
||||
return entities.RuleJudgeResult(matching=found and bool(should_respond_at), replacement=message_chain)
|
||||
|
||||
return entities.RuleJudgeResult(matching=found, replacement=message_chain)
|
||||
|
||||
@@ -66,22 +66,27 @@ class RuntimeBot:
|
||||
message_session_id=f'person_{event.sender.id}',
|
||||
)
|
||||
|
||||
# Push to webhooks
|
||||
# Push to webhooks and check if pipeline should be skipped
|
||||
skip_pipeline = False
|
||||
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
|
||||
asyncio.create_task(
|
||||
self.ap.webhook_pusher.push_person_message(event, self.bot_entity.uuid, adapter.__class__.__name__)
|
||||
skip_pipeline = await self.ap.webhook_pusher.push_person_message(
|
||||
event, self.bot_entity.uuid, adapter.__class__.__name__
|
||||
)
|
||||
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=event.sender.id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
if not skip_pipeline:
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=event.sender.id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
else:
|
||||
await self.logger.info(f'Pipeline skipped for person message due to webhook response')
|
||||
|
||||
async def on_group_message(
|
||||
event: platform_events.GroupMessage,
|
||||
@@ -97,22 +102,27 @@ class RuntimeBot:
|
||||
message_session_id=f'group_{event.group.id}',
|
||||
)
|
||||
|
||||
# Push to webhooks
|
||||
# Push to webhooks and check if pipeline should be skipped
|
||||
skip_pipeline = False
|
||||
if hasattr(self.ap, 'webhook_pusher') and self.ap.webhook_pusher:
|
||||
asyncio.create_task(
|
||||
self.ap.webhook_pusher.push_group_message(event, self.bot_entity.uuid, adapter.__class__.__name__)
|
||||
skip_pipeline = await self.ap.webhook_pusher.push_group_message(
|
||||
event, self.bot_entity.uuid, adapter.__class__.__name__
|
||||
)
|
||||
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||
launcher_id=event.group.id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
if not skip_pipeline:
|
||||
await self.ap.query_pool.add_query(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||
launcher_id=event.group.id,
|
||||
sender_id=event.sender.id,
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
else:
|
||||
await self.logger.info(f'Pipeline skipped for group message due to webhook response')
|
||||
|
||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||
|
||||
@@ -327,9 +327,6 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title=event.sender['title'] if 'title' in event.sender else '',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=yiri_chain,
|
||||
time=event.time,
|
||||
|
||||
@@ -119,9 +119,6 @@ class DingTalkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = event.incoming_message.create_at
|
||||
return platform_events.GroupMessage(
|
||||
@@ -263,7 +260,8 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.bot.start()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
return False
|
||||
await self.bot.stop()
|
||||
return True
|
||||
|
||||
async def is_muted(self) -> bool:
|
||||
return False
|
||||
|
||||
@@ -8,6 +8,9 @@ import base64
|
||||
import uuid
|
||||
import os
|
||||
import datetime
|
||||
|
||||
# 使用BytesIO创建文件对象,避免路径问题
|
||||
import io
|
||||
import asyncio
|
||||
from enum import Enum
|
||||
|
||||
@@ -594,7 +597,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
|
||||
break
|
||||
|
||||
text_string = ''
|
||||
image_files = []
|
||||
files = []
|
||||
|
||||
for ele in message_chain:
|
||||
if isinstance(ele, platform_message.Image):
|
||||
@@ -668,22 +671,67 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
|
||||
continue # 跳过读取失败的文件
|
||||
|
||||
if image_bytes:
|
||||
# 使用BytesIO创建文件对象,避免路径问题
|
||||
import io
|
||||
|
||||
image_files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
|
||||
files.append(discord.File(fp=io.BytesIO(image_bytes), filename=filename))
|
||||
elif isinstance(ele, platform_message.Plain):
|
||||
text_string += ele.text
|
||||
elif isinstance(ele, platform_message.Voice):
|
||||
file_bytes = None
|
||||
filename = f'{uuid.uuid4()}.mp3'
|
||||
if ele.base64:
|
||||
if ele.base64.startswith('data:'):
|
||||
data_header = ele.base64.split(',')[0]
|
||||
if 'wav' in data_header:
|
||||
filename = f'{uuid.uuid4()}.wav'
|
||||
elif 'mp3' in data_header:
|
||||
filename = f'{uuid.uuid4()}.mp3'
|
||||
elif 'ogg' in data_header:
|
||||
filename = f'{uuid.uuid4()}.ogg'
|
||||
elif 'm4a' in data_header:
|
||||
filename = f'{uuid.uuid4()}.m4a'
|
||||
elif 'aac' in data_header:
|
||||
filename = f'{uuid.uuid4()}.aac'
|
||||
elif 'flac' in data_header:
|
||||
filename = f'{uuid.uuid4()}.flac'
|
||||
elif 'alac' in data_header:
|
||||
filename = f'{uuid.uuid4()}.alac'
|
||||
elif 'opus' in data_header:
|
||||
filename = f'{uuid.uuid4()}.opus'
|
||||
elif 'webm' in data_header:
|
||||
filename = f'{uuid.uuid4()}.webm'
|
||||
|
||||
file_base64 = ele.base64.split(',')[-1]
|
||||
file_bytes = base64.b64decode(file_base64)
|
||||
elif ele.url:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(ele.url) as response:
|
||||
file_bytes = await response.read()
|
||||
if file_bytes:
|
||||
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
|
||||
elif isinstance(ele, platform_message.File):
|
||||
file_bytes = None
|
||||
filename = f'{uuid.uuid4()}.{ele.name.split(".")[-1]}'
|
||||
if ele.base64:
|
||||
if ele.base64.startswith('data:'):
|
||||
file_base64 = ele.base64.split(',')[1]
|
||||
file_bytes = base64.b64decode(file_base64)
|
||||
else:
|
||||
file_bytes = base64.b64decode(ele.base64)
|
||||
elif ele.url:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(ele.url) as response:
|
||||
file_bytes = await response.read()
|
||||
if file_bytes:
|
||||
files.append(discord.File(fp=io.BytesIO(file_bytes), filename=filename))
|
||||
elif isinstance(ele, platform_message.Forward):
|
||||
for node in ele.node_list:
|
||||
(
|
||||
node_text,
|
||||
node_images,
|
||||
node_files,
|
||||
) = await DiscordMessageConverter.yiri2target(node.message_chain)
|
||||
text_string += node_text
|
||||
image_files.extend(node_images)
|
||||
files.extend(node_files)
|
||||
|
||||
return text_string, image_files
|
||||
return text_string, files
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(message: discord.Message) -> platform_message.MessageChain:
|
||||
@@ -769,9 +817,6 @@ class DiscordEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.created_at.timestamp(),
|
||||
@@ -993,7 +1038,7 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.voice_manager.cleanup_inactive_connections()
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
msg_to_send, image_files = await self.message_converter.yiri2target(message)
|
||||
msg_to_send, files = await self.message_converter.yiri2target(message)
|
||||
|
||||
try:
|
||||
# 获取频道对象
|
||||
@@ -1006,8 +1051,8 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
'content': msg_to_send,
|
||||
}
|
||||
|
||||
if len(image_files) > 0:
|
||||
args['files'] = image_files
|
||||
if len(files) > 0:
|
||||
args['files'] = files
|
||||
|
||||
await channel.send(**args)
|
||||
|
||||
@@ -1021,15 +1066,16 @@ class DiscordAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
msg_to_send, image_files = await self.message_converter.yiri2target(message)
|
||||
msg_to_send, files = await self.message_converter.yiri2target(message)
|
||||
|
||||
assert isinstance(message_source.source_platform_object, discord.Message)
|
||||
|
||||
args = {
|
||||
'content': msg_to_send,
|
||||
}
|
||||
|
||||
if len(image_files) > 0:
|
||||
args['files'] = image_files
|
||||
if len(files) > 0:
|
||||
args['files'] = files
|
||||
|
||||
if quote_origin:
|
||||
args['reference'] = message_source.source_platform_object
|
||||
|
||||
BIN
src/langbot/pkg/platform/sources/kook.png
Normal file
BIN
src/langbot/pkg/platform/sources/kook.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
680
src/langbot/pkg/platform/sources/kook.py
Normal file
680
src/langbot/pkg/platform/sources/kook.py
Normal file
@@ -0,0 +1,680 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import asyncio
|
||||
import json
|
||||
import base64
|
||||
import zlib
|
||||
import traceback
|
||||
import time
|
||||
|
||||
import aiohttp
|
||||
import websockets
|
||||
import pydantic
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
class KookMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
"""Convert between LangBot MessageChain and KOOK message format"""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain) -> tuple[str, int]:
|
||||
"""
|
||||
Convert LangBot MessageChain to KOOK message format
|
||||
|
||||
Returns:
|
||||
tuple: (content, message_type)
|
||||
- content: message content string
|
||||
- message_type: 1=text, 2=image, 4=file, 9=KMarkdown
|
||||
"""
|
||||
content_parts = []
|
||||
message_type = 1 # Default to text
|
||||
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
content_parts.append(component.text)
|
||||
elif isinstance(component, platform_message.At):
|
||||
# KOOK mention format: (met)user_id(met)
|
||||
if component.target:
|
||||
content_parts.append(f'(met){component.target}(met)')
|
||||
elif isinstance(component, platform_message.AtAll):
|
||||
# KOOK @all format: (met)all(met)
|
||||
content_parts.append('(met)all(met)')
|
||||
elif isinstance(component, platform_message.Image):
|
||||
# For images, we need to upload first via KOOK's asset API
|
||||
# For now, we'll send the image URL if available
|
||||
if component.url:
|
||||
content_parts.append(component.url)
|
||||
message_type = 2 # Image message type
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
# Handle forward messages by concatenating content
|
||||
for node in component.node_list:
|
||||
forward_content, _ = await KookMessageConverter.yiri2target(node.message_chain)
|
||||
content_parts.append(forward_content)
|
||||
# Ignore Source and other components
|
||||
|
||||
content = ''.join(content_parts)
|
||||
return content, message_type
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(kook_message: dict, bot_account_id: str = '') -> platform_message.MessageChain:
|
||||
"""
|
||||
Convert KOOK message format to LangBot MessageChain
|
||||
|
||||
Args:
|
||||
kook_message: KOOK message event data dict
|
||||
bot_account_id: Bot's account ID for handling role mentions
|
||||
"""
|
||||
components = []
|
||||
|
||||
msg_type = kook_message.get('type', 1)
|
||||
content = kook_message.get('content', '')
|
||||
extra = kook_message.get('extra', {})
|
||||
|
||||
# Handle mentions
|
||||
mentions = extra.get('mention', [])
|
||||
mention_all = extra.get('mention_all', False)
|
||||
mention_roles = extra.get('mention_roles', [])
|
||||
|
||||
if mention_all:
|
||||
components.append(platform_message.AtAll())
|
||||
|
||||
for mention_id in mentions:
|
||||
components.append(platform_message.At(target=str(mention_id)))
|
||||
|
||||
# Handle role mentions (when bot is mentioned via role)
|
||||
# In KOOK, when a role that the bot has is mentioned, we receive it as a role mention
|
||||
# We need to convert this to an At with the bot's account ID for the pipeline to recognize it
|
||||
if mention_roles and bot_account_id:
|
||||
# Add an At component with the bot's account ID when any role is mentioned
|
||||
# This is because KOOK bots are often assigned roles and @role mentions should trigger responses
|
||||
components.append(platform_message.At(target=bot_account_id))
|
||||
|
||||
# Strip mention patterns from content
|
||||
# Remove user mention patterns: (met)USER_ID(met)
|
||||
for mention_id in mentions:
|
||||
content = content.replace(f'(met){mention_id}(met)', '')
|
||||
|
||||
# Remove @all pattern
|
||||
if mention_all:
|
||||
content = content.replace('(met)all(met)', '')
|
||||
|
||||
# Remove role mention patterns: (rol)ROLE_ID(rol)
|
||||
for role_id in mention_roles:
|
||||
content = content.replace(f'(rol){role_id}(rol)', '')
|
||||
|
||||
# Clean up extra whitespace
|
||||
content = content.strip()
|
||||
|
||||
# Handle different message types
|
||||
if msg_type == 1: # Text message
|
||||
if content:
|
||||
components.append(platform_message.Plain(text=content))
|
||||
elif msg_type == 2: # Image message
|
||||
# Image content is typically a URL
|
||||
if content:
|
||||
# Download image and convert to base64
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(content) as response:
|
||||
if response.status == 200:
|
||||
image_bytes = await response.read()
|
||||
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
|
||||
# Detect image format
|
||||
content_type = response.headers.get('Content-Type', 'image/png')
|
||||
components.append(
|
||||
platform_message.Image(base64=f'data:{content_type};base64,{image_base64}')
|
||||
)
|
||||
except Exception:
|
||||
# If download fails, just add as plain text
|
||||
components.append(platform_message.Plain(text=f'[Image: {content}]'))
|
||||
elif msg_type == 4: # File message
|
||||
# For file messages, content is typically the file URL
|
||||
attachments = extra.get('attachments', {})
|
||||
file_name = attachments.get('name', 'file')
|
||||
components.append(platform_message.File(url=content, name=file_name))
|
||||
elif msg_type == 8: # Audio message
|
||||
# For audio messages, content is typically the audio URL
|
||||
attachments = extra.get('attachments', {})
|
||||
components.append(platform_message.Voice(url=content))
|
||||
elif msg_type == 9: # KMarkdown message
|
||||
# Note: content is already stripped of mention patterns above
|
||||
if content:
|
||||
components.append(platform_message.Plain(text=content))
|
||||
elif msg_type == 10: # Card message
|
||||
# Card messages are complex, for now just indicate it's a card
|
||||
components.append(platform_message.Plain(text='[Card Message]'))
|
||||
else:
|
||||
# Other message types, just use content as plain text
|
||||
if content:
|
||||
components.append(platform_message.Plain(text=content))
|
||||
|
||||
return platform_message.MessageChain(components)
|
||||
|
||||
|
||||
class KookEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
"""Convert between LangBot events and KOOK events"""
|
||||
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent):
|
||||
"""Convert LangBot event to KOOK event (not implemented)"""
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(kook_event: dict, bot_account_id: str = '') -> platform_events.MessageEvent:
|
||||
"""
|
||||
Convert KOOK event to LangBot MessageEvent
|
||||
|
||||
Args:
|
||||
kook_event: KOOK event data dict containing channel_type, type, etc.
|
||||
bot_account_id: Bot's account ID for handling role mentions
|
||||
|
||||
Returns:
|
||||
FriendMessage or GroupMessage depending on channel_type
|
||||
"""
|
||||
channel_type = kook_event.get('channel_type')
|
||||
author_id = kook_event.get('author_id')
|
||||
target_id = kook_event.get('target_id')
|
||||
msg_timestamp = kook_event.get('msg_timestamp', int(time.time() * 1000))
|
||||
extra = kook_event.get('extra', {})
|
||||
|
||||
# Convert message to MessageChain
|
||||
message_chain = await KookMessageConverter.target2yiri(kook_event, bot_account_id)
|
||||
|
||||
# Convert timestamp from milliseconds to seconds
|
||||
event_time = msg_timestamp / 1000.0
|
||||
|
||||
if channel_type == 'PERSON':
|
||||
# Direct/Private message
|
||||
author = extra.get('author', {})
|
||||
author_name = author.get('nickname', author.get('username', str(author_id)))
|
||||
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=str(author_id),
|
||||
nickname=author_name,
|
||||
remark=str(author_id),
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event_time,
|
||||
source_platform_object=kook_event,
|
||||
)
|
||||
elif channel_type == 'GROUP':
|
||||
# Guild/Server channel message
|
||||
author = extra.get('author', {})
|
||||
author_name = author.get('nickname', author.get('username', str(author_id)))
|
||||
|
||||
# guild_id = extra.get('guild_id', '')
|
||||
channel_name = extra.get('channel_name', str(target_id))
|
||||
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=str(author_id),
|
||||
member_name=author_name,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=str(target_id), # Channel ID
|
||||
name=channel_name,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event_time,
|
||||
source_platform_object=kook_event,
|
||||
)
|
||||
else:
|
||||
# Fallback to FriendMessage for unknown channel types
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=str(author_id),
|
||||
nickname=str(author_id),
|
||||
remark=str(author_id),
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event_time,
|
||||
source_platform_object=kook_event,
|
||||
)
|
||||
|
||||
|
||||
class KookAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""KOOK platform adapter for LangBot"""
|
||||
|
||||
config: dict
|
||||
message_converter: KookMessageConverter = KookMessageConverter()
|
||||
event_converter: KookEventConverter = KookEventConverter()
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||
] = {}
|
||||
|
||||
# WebSocket connection
|
||||
ws: typing.Optional[websockets.WebSocketClientProtocol] = pydantic.Field(exclude=True, default=None)
|
||||
ws_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
|
||||
heartbeat_task: typing.Optional[asyncio.Task] = pydantic.Field(exclude=True, default=None)
|
||||
running: bool = pydantic.Field(exclude=True, default=False)
|
||||
|
||||
# Connection state
|
||||
session_id: str = pydantic.Field(exclude=True, default='')
|
||||
current_sn: int = pydantic.Field(exclude=True, default=0)
|
||||
gateway_url: str = pydantic.Field(exclude=True, default='')
|
||||
|
||||
# HTTP session
|
||||
http_session: typing.Optional[aiohttp.ClientSession] = pydantic.Field(exclude=True, default=None)
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
# Debug: Track init
|
||||
with open('/tmp/kook_adapter_init.txt', 'w') as f:
|
||||
f.write(f'KOOK adapter __init__ called at {time.time()}\n')
|
||||
|
||||
# Validate required config
|
||||
if 'token' not in config:
|
||||
raise Exception('KOOK adapter requires "token" in config')
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot_account_id='', # Will be set after connection
|
||||
listeners={},
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
async def _get_gateway_url(self) -> str:
|
||||
"""Get WebSocket gateway URL from KOOK API"""
|
||||
base_url = 'https://www.kookapp.cn/api/v3/gateway/index'
|
||||
|
||||
# Always use compression for better performance
|
||||
params = {'compress': 1}
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bot {self.config["token"]}',
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(base_url, params=params, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
if data.get('code') == 0:
|
||||
gateway_url = data['data']['url']
|
||||
return gateway_url
|
||||
else:
|
||||
raise Exception(f'Failed to get gateway URL: {data.get("message")}')
|
||||
else:
|
||||
raise Exception(f'Failed to get gateway URL: HTTP {response.status}')
|
||||
|
||||
async def _get_bot_user_info(self) -> dict:
|
||||
"""Get bot's own user information from KOOK API"""
|
||||
base_url = 'https://www.kookapp.cn/api/v3/user/me'
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bot {self.config["token"]}',
|
||||
}
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(base_url, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
data = await response.json()
|
||||
if data.get('code') == 0:
|
||||
user_info = data['data']
|
||||
return user_info
|
||||
else:
|
||||
raise Exception(f'Failed to get bot user info: {data.get("message")}')
|
||||
else:
|
||||
raise Exception(f'Failed to get bot user info: HTTP {response.status}')
|
||||
|
||||
async def _handle_hello(self, data: dict):
|
||||
"""Handle HELLO signal (signal 1)"""
|
||||
session_id = data.get('session_id', '')
|
||||
self.session_id = session_id
|
||||
await self.logger.info(f'KOOK WebSocket HELLO received, session_id: {session_id}')
|
||||
|
||||
async def _handle_event(self, data: dict, sn: int):
|
||||
"""Handle EVENT signal (signal 0)"""
|
||||
self.current_sn = max(self.current_sn, sn)
|
||||
|
||||
# Check if this is a message event
|
||||
event_type = data.get('type')
|
||||
channel_type = data.get('channel_type')
|
||||
author_id = data.get('author_id')
|
||||
|
||||
# Ignore messages from bot itself to prevent infinite loops
|
||||
if self.bot_account_id and str(author_id) == self.bot_account_id:
|
||||
return
|
||||
|
||||
# Only process text messages (type 1, 2, 4, 8, 9, 10) in GROUP or PERSON channels
|
||||
if event_type in [1, 2, 4, 8, 9, 10] and channel_type in ['GROUP', 'PERSON']:
|
||||
try:
|
||||
# Convert to LangBot event
|
||||
lb_event = await self.event_converter.target2yiri(data, self.bot_account_id)
|
||||
|
||||
# Call registered listener
|
||||
event_class = type(lb_event)
|
||||
if event_class in self.listeners:
|
||||
await self.listeners[event_class](lb_event, self)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error handling KOOK event: {e}\n{traceback.format_exc()}')
|
||||
|
||||
async def _handle_pong(self, data: dict):
|
||||
"""Handle PONG signal (signal 3)"""
|
||||
# PONG received, connection is healthy
|
||||
pass
|
||||
|
||||
async def _heartbeat_loop(self):
|
||||
"""Send PING every 30 seconds"""
|
||||
try:
|
||||
while self.running and self.ws:
|
||||
await asyncio.sleep(30)
|
||||
|
||||
if self.ws:
|
||||
try:
|
||||
ping_msg = {
|
||||
's': 2, # PING signal
|
||||
'sn': self.current_sn,
|
||||
}
|
||||
await self.ws.send(json.dumps(ping_msg))
|
||||
except Exception:
|
||||
# Connection closed or send failed, exit loop
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Heartbeat error: {e}')
|
||||
|
||||
async def _websocket_loop(self):
|
||||
"""Main WebSocket event loop"""
|
||||
retry_count = 0
|
||||
max_retries = 3
|
||||
|
||||
while self.running and retry_count < max_retries:
|
||||
try:
|
||||
# Get gateway URL if not already retrieved
|
||||
if not self.gateway_url:
|
||||
self.gateway_url = await self._get_gateway_url()
|
||||
|
||||
# Connect to WebSocket
|
||||
async with websockets.connect(self.gateway_url) as ws:
|
||||
await self.logger.info(f'Connected to KOOK WebSocket: {self.gateway_url}')
|
||||
self.ws = ws
|
||||
|
||||
# Start heartbeat
|
||||
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
# Wait for HELLO within 6 seconds
|
||||
try:
|
||||
hello_msg = await asyncio.wait_for(ws.recv(), timeout=6.0)
|
||||
|
||||
# Handle compressed messages (same as main message loop)
|
||||
if isinstance(hello_msg, bytes):
|
||||
# Decompress if compressed
|
||||
try:
|
||||
hello_msg = zlib.decompress(hello_msg).decode('utf-8')
|
||||
except Exception:
|
||||
# Not compressed or decompression failed
|
||||
hello_msg = hello_msg.decode('utf-8')
|
||||
|
||||
hello_data = json.loads(hello_msg)
|
||||
|
||||
if hello_data.get('s') == 1: # HELLO signal
|
||||
await self._handle_hello(hello_data['d'])
|
||||
else:
|
||||
raise Exception(f'Expected HELLO signal, got signal {hello_data.get("s")}')
|
||||
except asyncio.TimeoutError:
|
||||
raise Exception('Did not receive HELLO within 6 seconds')
|
||||
|
||||
# Reset retry count on successful connection
|
||||
retry_count = 0
|
||||
|
||||
# Main message loop
|
||||
async for message in ws:
|
||||
if isinstance(message, bytes):
|
||||
# Decompress if compressed
|
||||
try:
|
||||
message = zlib.decompress(message).decode('utf-8')
|
||||
except Exception:
|
||||
# Not compressed or decompression failed
|
||||
message = message.decode('utf-8')
|
||||
|
||||
try:
|
||||
msg_data = json.loads(message)
|
||||
signal = msg_data.get('s')
|
||||
|
||||
if signal == 0: # EVENT
|
||||
data = msg_data.get('d', {})
|
||||
sn = msg_data.get('sn', 0)
|
||||
await self._handle_event(data, sn)
|
||||
elif signal == 3: # PONG
|
||||
await self._handle_pong(msg_data.get('d', {}))
|
||||
elif signal == 5: # RECONNECT
|
||||
# await self.logger.info('Received RECONNECT signal')
|
||||
break # Break to reconnect
|
||||
elif signal == 6: # RESUME ACK
|
||||
# await self.logger.info('Resume successful')
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
await self.logger.error(f'Failed to parse message: {message}')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error processing message: {e}\n{traceback.format_exc()}')
|
||||
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
await self.logger.warning('KOOK WebSocket connection closed, reconnecting...')
|
||||
retry_count += 1
|
||||
await asyncio.sleep(2**retry_count) # Exponential backoff
|
||||
except Exception as e:
|
||||
await self.logger.error(f'KOOK WebSocket error: {e}\n{traceback.format_exc()}')
|
||||
retry_count += 1
|
||||
await asyncio.sleep(2**retry_count)
|
||||
finally:
|
||||
# Stop heartbeat
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self.ws = None
|
||||
|
||||
if retry_count >= max_retries:
|
||||
await self.logger.error(f'Failed to connect after {max_retries} retries')
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
"""Send a message to a channel or user"""
|
||||
content, msg_type = await self.message_converter.yiri2target(message)
|
||||
|
||||
# Determine endpoint based on target_type
|
||||
if target_type == 'GROUP':
|
||||
# Send to channel
|
||||
url = 'https://www.kookapp.cn/api/v3/message/create'
|
||||
payload = {
|
||||
'target_id': target_id,
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
else: # PERSON or default
|
||||
# Send direct message
|
||||
url = 'https://www.kookapp.cn/api/v3/direct-message/create'
|
||||
payload = {
|
||||
'target_id': target_id,
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bot {self.config["token"]}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
try:
|
||||
if not self.http_session:
|
||||
self.http_session = aiohttp.ClientSession()
|
||||
|
||||
async with self.http_session.post(url, json=payload, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
if result.get('code') == 0:
|
||||
await self.logger.debug(f'Message sent successfully to {target_id}')
|
||||
else:
|
||||
await self.logger.error(f'Failed to send message: {result.get("message")}')
|
||||
else:
|
||||
await self.logger.error(f'Failed to send message: HTTP {response.status}')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error sending message: {e}')
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
"""Reply to a message"""
|
||||
content, msg_type = await self.message_converter.yiri2target(message)
|
||||
|
||||
kook_event = message_source.source_platform_object
|
||||
channel_type = kook_event.get('channel_type')
|
||||
target_id = kook_event.get('target_id')
|
||||
msg_id = kook_event.get('msg_id')
|
||||
|
||||
# Determine endpoint based on channel_type
|
||||
if channel_type == 'GROUP':
|
||||
url = 'https://www.kookapp.cn/api/v3/message/create'
|
||||
payload = {
|
||||
'target_id': target_id,
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
else: # PERSON
|
||||
url = 'https://www.kookapp.cn/api/v3/direct-message/create'
|
||||
# For direct messages, we need the chat_code or target_id
|
||||
author_id = kook_event.get('author_id')
|
||||
extra = kook_event.get('extra', {})
|
||||
chat_code = extra.get('code', '')
|
||||
|
||||
payload = {
|
||||
'content': content,
|
||||
'type': msg_type,
|
||||
}
|
||||
|
||||
if chat_code:
|
||||
payload['chat_code'] = chat_code
|
||||
else:
|
||||
payload['target_id'] = str(author_id)
|
||||
|
||||
# Add quote if requested
|
||||
if quote_origin and msg_id:
|
||||
payload['quote'] = msg_id
|
||||
|
||||
payload['reply_msg_id'] = msg_id
|
||||
|
||||
headers = {
|
||||
'Authorization': f'Bot {self.config["token"]}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
try:
|
||||
if not self.http_session:
|
||||
self.http_session = aiohttp.ClientSession()
|
||||
|
||||
async with self.http_session.post(url, json=payload, headers=headers) as response:
|
||||
if response.status == 200:
|
||||
result = await response.json()
|
||||
if result.get('code') == 0:
|
||||
await self.logger.debug('Reply sent successfully')
|
||||
else:
|
||||
await self.logger.error(f'Failed to send reply: {result.get("message")}')
|
||||
else:
|
||||
await self.logger.error(f'Failed to send reply: HTTP {response.status}')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Error sending reply: {e}')
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
"""Check if bot is muted in a group (not implemented for KOOK)"""
|
||||
return False
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
"""Register an event listener"""
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
"""Unregister an event listener"""
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def run_async(self):
|
||||
"""Start the KOOK adapter"""
|
||||
# Debug: Track run_async
|
||||
with open('/tmp/kook_adapter_run.txt', 'w') as f:
|
||||
f.write(f'KOOK adapter run_async called at {time.time()}\n')
|
||||
|
||||
self.running = True
|
||||
|
||||
try:
|
||||
# Create HTTP session
|
||||
self.http_session = aiohttp.ClientSession()
|
||||
|
||||
await self.logger.info('Starting KOOK adapter')
|
||||
|
||||
# Get bot's user information and set bot_account_id
|
||||
try:
|
||||
bot_info = await self._get_bot_user_info()
|
||||
self.bot_account_id = str(bot_info.get('id', ''))
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to get bot user info: {e}')
|
||||
# Continue anyway, but bot will process its own messages
|
||||
|
||||
# Start WebSocket connection
|
||||
self.ws_task = asyncio.create_task(self._websocket_loop())
|
||||
|
||||
# Keep running
|
||||
await self.ws_task
|
||||
except Exception as e:
|
||||
await self.logger.error(f'KOOK adapter error: {e}\n{traceback.format_exc()}')
|
||||
finally:
|
||||
self.running = False
|
||||
|
||||
async def kill(self) -> bool:
|
||||
"""Stop the KOOK adapter"""
|
||||
self.running = False
|
||||
|
||||
# Cancel tasks
|
||||
if self.heartbeat_task:
|
||||
self.heartbeat_task.cancel()
|
||||
try:
|
||||
await self.heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
if self.ws_task:
|
||||
self.ws_task.cancel()
|
||||
try:
|
||||
await self.ws_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Close WebSocket
|
||||
if self.ws:
|
||||
try:
|
||||
await self.ws.close()
|
||||
except Exception:
|
||||
pass # Already closed or error during close
|
||||
|
||||
# Close HTTP session
|
||||
if self.http_session:
|
||||
await self.http_session.close()
|
||||
|
||||
await self.logger.info('KOOK adapter stopped')
|
||||
return True
|
||||
24
src/langbot/pkg/platform/sources/kook.yaml
Normal file
24
src/langbot/pkg/platform/sources/kook.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: kook
|
||||
label:
|
||||
en_US: KOOK
|
||||
zh_Hans: KOOK
|
||||
description:
|
||||
en_US: KOOK Adapter (formerly KaiHeiLa)
|
||||
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
|
||||
icon: kook.png
|
||||
spec:
|
||||
config:
|
||||
- name: token
|
||||
label:
|
||||
en_US: Bot Token
|
||||
zh_Hans: 机器人令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./kook.py
|
||||
attr: KookAdapter
|
||||
@@ -9,9 +9,13 @@ import re
|
||||
import base64
|
||||
import uuid
|
||||
import json
|
||||
import time
|
||||
import datetime
|
||||
import hashlib
|
||||
from Crypto.Cipher import AES
|
||||
import tempfile
|
||||
import os
|
||||
import mimetypes
|
||||
|
||||
import aiohttp
|
||||
import lark_oapi.ws.exception
|
||||
@@ -19,6 +23,8 @@ import quart
|
||||
from lark_oapi.api.im.v1 import *
|
||||
import pydantic
|
||||
from lark_oapi.api.cardkit.v1 import *
|
||||
from lark_oapi.api.auth.v3 import *
|
||||
from lark_oapi.core.model import *
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
@@ -55,9 +61,7 @@ class AESCipher(object):
|
||||
|
||||
class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def upload_image_to_lark(
|
||||
msg: platform_message.Image, api_client: lark_oapi.Client
|
||||
) -> typing.Optional[str]:
|
||||
async def upload_image_to_lark(msg: platform_message.Image, api_client: lark_oapi.Client) -> typing.Optional[str]:
|
||||
"""Upload an image to Lark and return the image_key, or None if upload fails."""
|
||||
image_bytes = None
|
||||
|
||||
@@ -95,7 +99,9 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
return None
|
||||
|
||||
if image_bytes is None:
|
||||
print(f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})')
|
||||
print(
|
||||
f'No image data available for Image message (url={msg.url}, base64={bool(msg.base64)}, path={msg.path})'
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -113,10 +119,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
request = (
|
||||
CreateImageRequest.builder()
|
||||
.request_body(
|
||||
CreateImageRequestBody.builder()
|
||||
.image_type('message')
|
||||
.image(open(temp_file_path, 'rb'))
|
||||
.build()
|
||||
CreateImageRequestBody.builder().image_type('message').image(open(temp_file_path, 'rb')).build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
@@ -143,7 +146,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
message_chain: platform_message.MessageChain, api_client: lark_oapi.Client
|
||||
) -> typing.Tuple[list, list]:
|
||||
"""Convert message chain to Lark format.
|
||||
|
||||
|
||||
Returns:
|
||||
Tuple of (text_elements, image_keys):
|
||||
- text_elements: List of paragraphs for post message format
|
||||
@@ -159,24 +162,24 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
async def process_text_with_images(text: str) -> typing.Tuple[str, list]:
|
||||
"""Extract Markdown images from text and return cleaned text + image URLs."""
|
||||
extracted_urls = []
|
||||
|
||||
|
||||
# Find all Markdown images
|
||||
matches = list(markdown_image_pattern.finditer(text))
|
||||
if not matches:
|
||||
return text, []
|
||||
|
||||
|
||||
# Extract URLs and remove image syntax from text
|
||||
cleaned_text = text
|
||||
for match in reversed(matches): # Reverse to maintain correct positions
|
||||
url = match.group(2)
|
||||
extracted_urls.insert(0, url) # Insert at beginning since we're going in reverse
|
||||
# Replace image syntax with empty string or a placeholder
|
||||
cleaned_text = cleaned_text[:match.start()] + cleaned_text[match.end():]
|
||||
|
||||
cleaned_text = cleaned_text[: match.start()] + cleaned_text[match.end() :]
|
||||
|
||||
# Clean up multiple consecutive newlines that might result from removing images
|
||||
cleaned_text = re.sub(r'\n{3,}', '\n\n', cleaned_text)
|
||||
cleaned_text = cleaned_text.strip()
|
||||
|
||||
|
||||
return cleaned_text, extracted_urls
|
||||
|
||||
for msg in message_chain:
|
||||
@@ -189,14 +192,14 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
text = msg.text.encode('latin1').decode('utf-8')
|
||||
except UnicodeError:
|
||||
text = msg.text.encode('utf-8', errors='replace').decode('utf-8')
|
||||
|
||||
|
||||
# Check for and extract Markdown images from text
|
||||
cleaned_text, extracted_urls = await process_text_with_images(text)
|
||||
|
||||
|
||||
# Add cleaned text if not empty
|
||||
if cleaned_text:
|
||||
pending_paragraph.append({'tag': 'md', 'text': cleaned_text})
|
||||
|
||||
|
||||
# Process extracted image URLs
|
||||
for url in extracted_urls:
|
||||
# Create a temporary Image message to upload
|
||||
@@ -204,7 +207,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
image_key = await LarkMessageConverter.upload_image_to_lark(temp_image, api_client)
|
||||
if image_key:
|
||||
image_keys.append(image_key)
|
||||
|
||||
|
||||
elif isinstance(msg, platform_message.At):
|
||||
pending_paragraph.append({'tag': 'at', 'user_id': msg.target, 'style': []})
|
||||
elif isinstance(msg, platform_message.AtAll):
|
||||
@@ -241,6 +244,7 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
|
||||
lb_msg_list.append(platform_message.Source(id=message.message_id, time=msg_create_time))
|
||||
|
||||
|
||||
if message.message_type == 'text':
|
||||
element_list = []
|
||||
|
||||
@@ -300,6 +304,14 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
message_content['content'] = new_list
|
||||
elif message.message_type == 'image':
|
||||
message_content['content'] = [{'tag': 'img', 'image_key': message_content['image_key'], 'style': []}]
|
||||
elif message.message_type == 'file':
|
||||
message_content['content'] = [
|
||||
{'tag': 'file', 'file_key': message_content['file_key'], 'file_name': message_content['file_name']}
|
||||
]
|
||||
elif message.message_type == 'audio':
|
||||
message_content['content'] = [
|
||||
{'tag': 'audio', 'file_key': message_content['file_key'], "duration": message_content.get('duration',0)}
|
||||
]
|
||||
|
||||
for ele in message_content['content']:
|
||||
if ele['tag'] == 'text':
|
||||
@@ -330,6 +342,117 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
image_format = response.raw.headers['content-type']
|
||||
|
||||
lb_msg_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
|
||||
elif ele['tag'] == 'audio':
|
||||
file_key = ele['file_key']
|
||||
duration = ele['duration']
|
||||
|
||||
# Download audio file
|
||||
request: GetMessageResourceRequest = (
|
||||
GetMessageResourceRequest.builder()
|
||||
.message_id(message.message_id)
|
||||
.file_key(file_key)
|
||||
.type('file')
|
||||
.build()
|
||||
)
|
||||
|
||||
try:
|
||||
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
|
||||
|
||||
if not response.success():
|
||||
print(f'Failed to download audio: code: {response.code}, msg: {response.msg}')
|
||||
lb_msg_list.append(platform_message.Plain(text='[Audio file download failed]'))
|
||||
return platform_message.MessageChain(lb_msg_list)
|
||||
|
||||
# Read audio bytes
|
||||
audio_bytes = response.file.read()
|
||||
audio_base64 = base64.b64encode(audio_bytes).decode()
|
||||
|
||||
|
||||
# Get content type from response headers
|
||||
content_type = response.raw.headers.get('content-type', 'audio/mpeg')
|
||||
|
||||
|
||||
|
||||
mime_main = content_type.split(';')[0].strip()
|
||||
ext = mimetypes.guess_extension(mime_main) or '.bin'
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_file_path = os.path.join(temp_dir, f'lark_audio_{file_key}{ext}')
|
||||
|
||||
with open(temp_file_path, 'wb') as f:
|
||||
f.write(audio_bytes)
|
||||
|
||||
# Create Voice message: prefer path/url + length, include base64 as optional data URI
|
||||
lb_msg_list.append(
|
||||
platform_message.Voice(
|
||||
voice_id=file_key,
|
||||
url=f'file://{temp_file_path}',
|
||||
path=temp_file_path,
|
||||
base64=f'data:{content_type};base64,{audio_base64}',
|
||||
length=(duration // 1000) if duration else None,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
print(f'Error downloading audio: {e}')
|
||||
traceback.print_exc()
|
||||
lb_msg_list.append(platform_message.Plain(text='[Audio file download error]'))
|
||||
|
||||
elif ele['tag'] == 'file':
|
||||
file_key = ele['file_key']
|
||||
file_name = ele['file_name']
|
||||
|
||||
request: GetMessageResourceRequest = (
|
||||
GetMessageResourceRequest.builder()
|
||||
.message_id(message.message_id)
|
||||
.file_key(file_key)
|
||||
.type('file')
|
||||
.build()
|
||||
)
|
||||
|
||||
response: GetMessageResourceResponse = await api_client.im.v1.message_resource.aget(request)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.message_resource.get failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
|
||||
file_bytes = response.file.read()
|
||||
file_base64 = base64.b64encode(file_bytes).decode()
|
||||
|
||||
|
||||
file_format = response.raw.headers['content-type']
|
||||
|
||||
file_size = len(file_bytes)
|
||||
|
||||
# Determine extension from content-type if possible
|
||||
content_type = response.raw.headers.get('content-type', '')
|
||||
mime_main = content_type.split(';')[0].strip() if content_type else ''
|
||||
ext = mimetypes.guess_extension(mime_main) or ''
|
||||
|
||||
# Ensure a safe filename (avoid path components)
|
||||
safe_name = os.path.basename(file_name).replace('/', '_').replace('\\', '_')
|
||||
if ext and not safe_name.lower().endswith(ext.lower()):
|
||||
filename_with_ext = f'{safe_name}{ext}'
|
||||
else:
|
||||
filename_with_ext = safe_name
|
||||
|
||||
temp_dir = tempfile.gettempdir()
|
||||
temp_file_path = os.path.join(temp_dir, f'lark_{file_key}_{filename_with_ext}')
|
||||
|
||||
with open(temp_file_path, 'wb') as f:
|
||||
f.write(file_bytes)
|
||||
|
||||
# Create File message with local path and file:// URL
|
||||
lb_msg_list.append(
|
||||
platform_message.File(
|
||||
id=file_key,
|
||||
name=file_name,
|
||||
size=file_size,
|
||||
url=f'file://{temp_file_path}',
|
||||
path=temp_file_path,
|
||||
base64=f'data:{file_format};base64,{file_base64}', # not including base64 by default to save memory; can be added if needed
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
return platform_message.MessageChain(lb_msg_list)
|
||||
|
||||
@@ -356,6 +479,7 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.event.message.create_time,
|
||||
source_platform_object=event,
|
||||
)
|
||||
elif event.event.message.chat_type == 'group':
|
||||
return platform_events.GroupMessage(
|
||||
@@ -369,12 +493,10 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.event.message.create_time,
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
|
||||
@@ -391,6 +513,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
message_converter: LarkMessageConverter = LarkMessageConverter()
|
||||
event_converter: LarkEventConverter = LarkEventConverter()
|
||||
cipher: AESCipher
|
||||
|
||||
listeners: typing.Dict[
|
||||
typing.Type[platform_events.Event],
|
||||
@@ -402,51 +525,15 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
card_id_dict: dict[str, str] # 消息id到卡片id的映射,便于创建卡片后的发送消息到指定卡片
|
||||
|
||||
seq: int # 用于在发送卡片消息中识别消息顺序,直接以seq作为标识
|
||||
bot_uuid: str = None # 机器人UUID
|
||||
app_ticket: str = None # 商店应用用到
|
||||
app_access_token: str = None # 商店应用用到
|
||||
app_access_token_expire_at: int = None
|
||||
tenant_access_tokens: dict[str, dict[str, str]] = {} # 租户access_token映射
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
quart_app = quart.Quart(__name__)
|
||||
|
||||
@quart_app.route('/lark/callback', methods=['POST'])
|
||||
async def lark_callback():
|
||||
try:
|
||||
data = await quart.request.json
|
||||
|
||||
if 'encrypt' in data:
|
||||
cipher = AESCipher(config['encrypt-key'])
|
||||
data = cipher.decrypt_string(data['encrypt'])
|
||||
data = json.loads(data)
|
||||
|
||||
type = data.get('type')
|
||||
if type is None:
|
||||
context = EventContext(data)
|
||||
type = context.header.event_type
|
||||
|
||||
if 'url_verification' == type:
|
||||
# todo 验证verification token
|
||||
return {'challenge': data.get('challenge')}
|
||||
context = EventContext(data)
|
||||
type = context.header.event_type
|
||||
p2v1 = P2ImMessageReceiveV1()
|
||||
p2v1.header = context.header
|
||||
event = P2ImMessageReceiveV1Data()
|
||||
event.message = EventMessage(context.event['message'])
|
||||
event.sender = EventSender(context.event['sender'])
|
||||
p2v1.event = event
|
||||
p2v1.schema = context.schema
|
||||
if 'im.message.receive_v1' == type:
|
||||
try:
|
||||
event = await self.event_converter.target2yiri(p2v1, self.api_client)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
|
||||
|
||||
if event.__class__ in self.listeners:
|
||||
await self.listeners[event.__class__](event, self)
|
||||
|
||||
return {'code': 200, 'message': 'ok'}
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
|
||||
return {'code': 500, 'message': 'error'}
|
||||
|
||||
async def on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||
lb_event = await self.event_converter.target2yiri(event, self.api_client)
|
||||
|
||||
@@ -462,7 +549,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot_account_id = config['bot_name']
|
||||
|
||||
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
|
||||
api_client = lark_oapi.Client.builder().app_id(config['app_id']).app_secret(config['app_secret']).build()
|
||||
api_client = self.build_api_client(config)
|
||||
cipher = AESCipher(config.get('encrypt-key', ''))
|
||||
self.request_app_ticket(api_client, config)
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
@@ -475,9 +564,105 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot=bot,
|
||||
api_client=api_client,
|
||||
bot_account_id=bot_account_id,
|
||||
cipher=cipher,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def request_app_ticket(self, api_client, config):
|
||||
app_id = config['app_id']
|
||||
app_secret = config['app_secret']
|
||||
print(f'Requesting app ticket for app_id: {app_id[:3]}***{app_id[-3:]}')
|
||||
if 'isv' == config.get('app_type', 'self'):
|
||||
request: ResendAppTicketRequest = (
|
||||
ResendAppTicketRequest.builder()
|
||||
.request_body(ResendAppTicketRequestBody.builder().app_id(app_id).app_secret(app_secret).build())
|
||||
.build()
|
||||
)
|
||||
response: ResendAppTicketResponse = api_client.auth.v3.app_ticket.resend(request)
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.auth.v3.auth.app_ticket_resend failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
|
||||
def request_app_access_token(self):
|
||||
app_id = self.config['app_id']
|
||||
app_secret = self.config['app_secret']
|
||||
if 'isv' == self.config.get('app_type', 'self'):
|
||||
request: CreateAppAccessTokenRequest = (
|
||||
CreateAppAccessTokenRequest.builder()
|
||||
.request_body(
|
||||
CreateAppAccessTokenRequestBody.builder()
|
||||
.app_id(app_id)
|
||||
.app_secret(app_secret)
|
||||
.app_ticket(self.app_ticket)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response: CreateAppAccessTokenResponse = self.api_client.auth.v3.app_access_token.create(request)
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.auth.v3.auth.app_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
content = json.loads(response.raw.content)
|
||||
self.app_access_token = content['app_access_token']
|
||||
self.app_access_token_expire_at = int(time.time()) + content['expire'] - 300
|
||||
|
||||
def get_app_access_token(self):
|
||||
if 'isv' != self.config.get('app_type', 'self'):
|
||||
return None
|
||||
if (
|
||||
self.app_access_token is None
|
||||
or self.app_access_token_expire_at is None
|
||||
or int(time.time()) >= self.app_access_token_expire_at
|
||||
):
|
||||
self.request_app_access_token()
|
||||
return self.app_access_token
|
||||
|
||||
def request_tenant_access_token(self, tenant_key: str):
|
||||
app_access_token = self.get_app_access_token()
|
||||
if 'isv' == self.config.get('app_type', 'self'):
|
||||
request: CreateTenantAccessTokenRequest = (
|
||||
CreateTenantAccessTokenRequest.builder()
|
||||
.request_body(
|
||||
CreateTenantAccessTokenRequestBody.builder()
|
||||
.app_access_token(app_access_token)
|
||||
.tenant_key(tenant_key)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
response: CreateTenantAccessTokenResponse = self.api_client.auth.v3.tenant_access_token.create(request)
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.auth.v3.auth.tenant_access_token failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
content = json.loads(response.raw.content)
|
||||
tenant_access_token = content['tenant_access_token']
|
||||
expire = content['expire']
|
||||
self.tenant_access_tokens[tenant_key] = {
|
||||
'token': tenant_access_token,
|
||||
'expire_at': int(time.time()) + expire - 300,
|
||||
}
|
||||
|
||||
def get_tenant_access_token(self, tenant_key: str):
|
||||
if tenant_key is None or 'isv' != self.config.get('app_type', 'self'):
|
||||
return None
|
||||
tenant_access_token = self.tenant_access_tokens.get(tenant_key)
|
||||
if tenant_access_token is None or int(time.time()) >= tenant_access_token['expire_at']:
|
||||
self.request_tenant_access_token(tenant_key)
|
||||
return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None
|
||||
|
||||
def build_api_client(self, config):
|
||||
app_id = config['app_id']
|
||||
app_secret = config['app_secret']
|
||||
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build()
|
||||
if 'isv' == config.get('app_type', 'self'):
|
||||
api_client = (
|
||||
lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build()
|
||||
)
|
||||
return api_client
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
pass
|
||||
|
||||
@@ -705,9 +890,19 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
)
|
||||
.build()
|
||||
)
|
||||
|
||||
tenant_key = event.source_platform_object.header.tenant_key if event.source_platform_object else None
|
||||
app_access_token = self.get_app_access_token()
|
||||
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||
req_opt: RequestOption = (
|
||||
RequestOption.builder()
|
||||
.app_ticket(self.app_ticket)
|
||||
.tenant_key(tenant_key)
|
||||
.app_access_token(app_access_token)
|
||||
.tenant_access_token(tenant_access_token)
|
||||
.build()
|
||||
)
|
||||
# 发起请求
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
|
||||
|
||||
# 处理失败返回
|
||||
if not response.success():
|
||||
@@ -734,7 +929,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
'content': text_elements,
|
||||
},
|
||||
}
|
||||
|
||||
request: ReplyMessageRequest = (
|
||||
ReplyMessageRequest.builder()
|
||||
.message_id(message_source.message_chain.message_id)
|
||||
@@ -749,7 +943,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
.build()
|
||||
)
|
||||
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
|
||||
tenant_key = (
|
||||
message_source.source_platform_object.header.tenant_key
|
||||
if message_source.source_platform_object
|
||||
else None
|
||||
)
|
||||
app_access_token = self.get_app_access_token()
|
||||
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||
req_opt: RequestOption = (
|
||||
RequestOption.builder()
|
||||
.app_ticket(self.app_ticket)
|
||||
.tenant_key(tenant_key)
|
||||
.app_access_token(app_access_token)
|
||||
.tenant_access_token(tenant_access_token)
|
||||
.build()
|
||||
)
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
@@ -774,7 +983,22 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
.build()
|
||||
)
|
||||
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request)
|
||||
tenant_key = (
|
||||
message_source.source_platform_object.header.tenant_key
|
||||
if message_source.source_platform_object
|
||||
else None
|
||||
)
|
||||
app_access_token = self.get_app_access_token()
|
||||
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||
req_opt: RequestOption = (
|
||||
RequestOption.builder()
|
||||
.app_ticket(self.app_ticket)
|
||||
.tenant_key(tenant_key)
|
||||
.app_access_token(app_access_token)
|
||||
.tenant_access_token(tenant_access_token)
|
||||
.build()
|
||||
)
|
||||
response: ReplyMessageResponse = await self.api_client.im.v1.message.areply(request, req_opt)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
@@ -828,8 +1052,24 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
# self.seq = 1 # 消息回复结束之后重置seq
|
||||
self.card_id_dict.pop(message_id) # 清理已经使用过的卡片
|
||||
|
||||
tenant_key = (
|
||||
message_source.source_platform_object.header.tenant_key
|
||||
if message_source.source_platform_object
|
||||
else None
|
||||
)
|
||||
app_access_token = self.get_app_access_token()
|
||||
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||
req_opt: RequestOption = (
|
||||
RequestOption.builder()
|
||||
.app_ticket(self.app_ticket)
|
||||
.tenant_key(tenant_key)
|
||||
.app_access_token(app_access_token)
|
||||
.tenant_access_token(tenant_access_token)
|
||||
.build()
|
||||
)
|
||||
# 发起请求
|
||||
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request)
|
||||
response: ContentCardElementResponse = self.api_client.cardkit.v1.card_element.content(request, req_opt)
|
||||
|
||||
# 处理失败返回
|
||||
if not response.success():
|
||||
@@ -859,8 +1099,110 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
):
|
||||
self.listeners.pop(event_type)
|
||||
|
||||
def set_bot_uuid(self, bot_uuid: str):
|
||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||
self.bot_uuid = bot_uuid
|
||||
|
||||
def get_event_type(self, data):
|
||||
schema = '1.0'
|
||||
if 'schema' in data:
|
||||
schema = data['schema']
|
||||
if '2.0' == schema:
|
||||
return data['header']['event_type']
|
||||
elif 'event' in data:
|
||||
return data['event']['type']
|
||||
else:
|
||||
return data['type']
|
||||
|
||||
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
||||
"""处理统一 webhook 请求。
|
||||
Args:
|
||||
bot_uuid: Bot 的 UUID
|
||||
path: 子路径(如果有的话)
|
||||
request: Quart Request 对象
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
try:
|
||||
data = await request.json
|
||||
|
||||
if 'encrypt' in data:
|
||||
data = self.cipher.decrypt_string(data['encrypt'])
|
||||
data = json.loads(data)
|
||||
type = self.get_event_type(data)
|
||||
context = EventContext(data)
|
||||
if 'url_verification' == type:
|
||||
# todo 验证verification token
|
||||
return {'challenge': data.get('challenge')}
|
||||
elif 'app_ticket' == type:
|
||||
self.app_ticket = context.event['app_ticket']
|
||||
elif 'im.message.receive_v1' == type:
|
||||
try:
|
||||
p2v1 = P2ImMessageReceiveV1()
|
||||
p2v1.header = context.header
|
||||
event = P2ImMessageReceiveV1Data()
|
||||
event.message = EventMessage(context.event['message'])
|
||||
event.sender = EventSender(context.event['sender'])
|
||||
p2v1.event = event
|
||||
p2v1.schema = context.schema
|
||||
event = await self.event_converter.target2yiri(p2v1, self.api_client)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
|
||||
|
||||
if event.__class__ in self.listeners:
|
||||
await self.listeners[event.__class__](event, self)
|
||||
elif 'im.chat.member.bot.added_v1' == type:
|
||||
try:
|
||||
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
|
||||
if bot_added_welcome_msg:
|
||||
final_content = {
|
||||
'zh_Hans': {
|
||||
'title': '',
|
||||
'content': [[{'tag': 'md', 'text': bot_added_welcome_msg}]],
|
||||
},
|
||||
}
|
||||
chat_id = context.event['chat_id']
|
||||
request: CreateMessageRequest = (
|
||||
CreateMessageRequest.builder()
|
||||
.receive_id_type('chat_id')
|
||||
.request_body(
|
||||
CreateMessageRequestBody.builder()
|
||||
.receive_id(chat_id)
|
||||
.content(json.dumps(final_content))
|
||||
.msg_type('post')
|
||||
.uuid(str(uuid.uuid4()))
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
)
|
||||
tenant_key = context.header.tenant_key if context.header else None
|
||||
app_access_token = self.get_app_access_token()
|
||||
tenant_access_token = self.get_tenant_access_token(tenant_key)
|
||||
req_opt: RequestOption = (
|
||||
RequestOption.builder()
|
||||
.app_ticket(self.app_ticket)
|
||||
.tenant_key(tenant_key)
|
||||
.app_access_token(app_access_token)
|
||||
.tenant_access_token(tenant_access_token)
|
||||
.build()
|
||||
)
|
||||
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
|
||||
|
||||
if not response.success():
|
||||
raise Exception(
|
||||
f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
|
||||
)
|
||||
except Exception as e:
|
||||
print(f'im.chat.member.bot.added_v1: {e}')
|
||||
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
|
||||
|
||||
return {'code': 200, 'message': 'ok'}
|
||||
except Exception as e:
|
||||
print(f'Error in lark callback: {e}')
|
||||
await self.logger.error(f'Error in lark callback: {traceback.format_exc()}')
|
||||
return {'code': 500, 'message': 'error'}
|
||||
|
||||
async def run_async(self):
|
||||
port = self.config['port']
|
||||
enable_webhook = self.config['enable-webhook']
|
||||
|
||||
if not enable_webhook:
|
||||
@@ -875,16 +1217,14 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
else:
|
||||
raise e
|
||||
else:
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
async def shutdown_trigger_placeholder():
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await self.quart_app.run_task(
|
||||
host='0.0.0.0',
|
||||
port=port,
|
||||
shutdown_trigger=shutdown_trigger_placeholder,
|
||||
)
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
# 需要断开连接,不然旧的连接会继续运行,导致飞书消息来时会随机选择一个连接
|
||||
|
||||
@@ -45,16 +45,6 @@ spec:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: port
|
||||
label:
|
||||
en_US: Webhook Port
|
||||
zh_Hans: Webhook端口
|
||||
description:
|
||||
en_US: Only valid when webhook mode is enabled, please fill in the webhook port
|
||||
zh_Hans: 仅在启用 Webhook 模式时有效,请填写 Webhook 端口
|
||||
type: integer
|
||||
required: true
|
||||
default: 2285
|
||||
- name: encrypt-key
|
||||
label:
|
||||
en_US: Encrypt Key
|
||||
@@ -75,6 +65,35 @@ spec:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: app_type
|
||||
label:
|
||||
en_US: App Type
|
||||
zh_Hans: 应用类型
|
||||
description:
|
||||
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
||||
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||
type: select
|
||||
options:
|
||||
- name: self
|
||||
label:
|
||||
en_US: Self-built Application
|
||||
zh_Hans: 自建应用
|
||||
- name: isv
|
||||
label:
|
||||
en_US: Store Application
|
||||
zh_Hans: 商店应用
|
||||
required: false
|
||||
default: self
|
||||
- name: bot_added_welcome
|
||||
label:
|
||||
en_US: Bot Welcome Message
|
||||
zh_Hans: 机器人进群欢迎语
|
||||
description:
|
||||
en_US: Welcome message when the bot is added to a group, supports Markdown format
|
||||
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
|
||||
type: text
|
||||
required: false
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./lark.py
|
||||
|
||||
@@ -437,9 +437,6 @@ class GewechatEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event['Data']['CreateTime'],
|
||||
|
||||
@@ -153,9 +153,6 @@ class NakuruProjectEventConverter(abstract_platform_adapter.AbstractEventConvert
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title=event.sender.title,
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=yiri_chain,
|
||||
time=event.time,
|
||||
|
||||
@@ -279,11 +279,6 @@ class OfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=int(
|
||||
datetime.datetime.strptime(event.member.joined_at, '%Y-%m-%dT%H:%M:%S%z').timestamp()
|
||||
),
|
||||
last_speak_timestamp=datetime.datetime.now().timestamp(),
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),
|
||||
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),
|
||||
@@ -312,9 +307,6 @@ class OfficialEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=int(0),
|
||||
last_speak_timestamp=datetime.datetime.now().timestamp(),
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=OfficialMessageConverter.extract_message_chain_from_obj(event, event.id),
|
||||
time=int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp()),
|
||||
|
||||
@@ -108,9 +108,6 @@ class LINEEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event.timestamp,
|
||||
@@ -262,19 +259,6 @@ class LINEAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
# 打印 webhook 回调地址
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
|
||||
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
|
||||
|
||||
await self.logger.info('LINE Webhook 回调地址:')
|
||||
await self.logger.info(f' 本地地址: {webhook_url}')
|
||||
await self.logger.info(f' 公网地址: {webhook_url_public}')
|
||||
await self.logger.info('请在 LINE 后台配置此回调地址')
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'无法生成 webhook URL: {e}')
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -155,20 +155,6 @@ class OfficialAccountAdapter(abstract_platform_adapter.AbstractMessagePlatformAd
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
# 打印 webhook 回调地址
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
|
||||
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
|
||||
|
||||
await self.logger.info('微信公众号 Webhook 回调地址:')
|
||||
await self.logger.info(f' 本地地址: {webhook_url}')
|
||||
await self.logger.info(f' 公网地址: {webhook_url_public}')
|
||||
await self.logger.info('请在微信公众号后台配置此回调地址')
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'无法生成 webhook URL: {e}')
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -94,9 +94,6 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp())
|
||||
return platform_events.GroupMessage(
|
||||
@@ -117,9 +114,6 @@ class QQOfficialEventConverter(abstract_platform_adapter.AbstractEventConverter)
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = int(datetime.datetime.strptime(event.timestamp, '%Y-%m-%dT%H:%M:%S%z').timestamp())
|
||||
return platform_events.GroupMessage(
|
||||
@@ -247,20 +241,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
# 打印 webhook 回调地址
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
|
||||
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
|
||||
|
||||
await self.logger.info('QQ 官方机器人 Webhook 回调地址:')
|
||||
await self.logger.info(f' 本地地址: {webhook_url}')
|
||||
await self.logger.info(f' 公网地址: {webhook_url_public}')
|
||||
await self.logger.info('请在 QQ 官方机器人后台配置此回调地址')
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'无法生成 webhook URL: {e}')
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -76,9 +76,6 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
id=event.channel_id, name='MEMBER', permission=platform_entities.Permission.Member
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = int(datetime.datetime.utcnow().timestamp())
|
||||
return platform_events.GroupMessage(
|
||||
@@ -112,10 +109,7 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
raise command_errors.ParamNotEnoughError('Slack机器人缺少相关配置项,请查看文档或联系管理员')
|
||||
|
||||
bot = SlackClient(
|
||||
bot_token=config['bot_token'],
|
||||
signing_secret=config['signing_secret'],
|
||||
logger=logger,
|
||||
unified_mode=True
|
||||
bot_token=config['bot_token'], signing_secret=config['signing_secret'], logger=logger, unified_mode=True
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
@@ -194,24 +188,10 @@ class SlackAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
async def run_async(self):
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
# 打印 webhook 回调地址
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
|
||||
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
|
||||
|
||||
await self.logger.info(f"Slack 机器人 Webhook 回调地址:")
|
||||
await self.logger.info(f" 本地地址: {webhook_url}")
|
||||
await self.logger.info(f" 公网地址: {webhook_url_public}")
|
||||
await self.logger.info(f"请在 Slack 后台配置此回调地址")
|
||||
except Exception as e:
|
||||
await self.logger.warning(f"无法生成 webhook URL: {e}")
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
|
||||
@@ -120,9 +120,6 @@ class TelegramEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=lb_message,
|
||||
time=event.message.date.timestamp(),
|
||||
|
||||
@@ -65,6 +65,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
outbound_message_queue: asyncio.Queue = pydantic.Field(default_factory=asyncio.Queue, exclude=True)
|
||||
"""后端主动推送消息的队列"""
|
||||
|
||||
# 流式输出开关
|
||||
stream_enabled: bool = pydantic.Field(default=True, exclude=True)
|
||||
"""是否启用流式输出"""
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
super().__init__(
|
||||
config=config,
|
||||
@@ -77,6 +81,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
self.bot_account_id = 'websocketbot'
|
||||
self.outbound_message_queue = asyncio.Queue()
|
||||
self.stream_enabled = True
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
@@ -97,8 +102,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
# 推送到所有相关连接
|
||||
await self.outbound_message_queue.put(message_data)
|
||||
|
||||
await self.logger.info(f'Send message to {target_id}: {message}')
|
||||
|
||||
return message_data
|
||||
|
||||
async def reply_message(
|
||||
@@ -214,8 +217,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
return message_data.model_dump()
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
"""WebSocket始终支持流式输出"""
|
||||
return True
|
||||
"""根据stream_enabled标志返回是否支持流式输出"""
|
||||
return self.stream_enabled
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
@@ -242,7 +245,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
async def run_async(self):
|
||||
"""运行适配器"""
|
||||
await self.logger.info('WebSocket适配器已启动')
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -258,12 +260,11 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
await asyncio.sleep(0.1)
|
||||
except asyncio.CancelledError:
|
||||
await self.logger.info('WebSocket适配器已停止')
|
||||
raise
|
||||
|
||||
async def kill(self):
|
||||
"""停止适配器"""
|
||||
await self.logger.info('WebSocket适配器正在停止')
|
||||
pass
|
||||
|
||||
async def _process_image_components(self, message_chain_obj: list):
|
||||
"""
|
||||
@@ -318,11 +319,16 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
Args:
|
||||
connection: WebSocket连接对象
|
||||
message_data: 消息数据
|
||||
message_data: 消息数据,包含:
|
||||
- message: 消息链
|
||||
- stream: 是否启用流式输出 (可选,默认True)
|
||||
"""
|
||||
pipeline_uuid = connection.pipeline_uuid
|
||||
session_type = connection.session_type
|
||||
|
||||
# 获取stream参数,默认为True
|
||||
self.stream_enabled = message_data.get('stream', True)
|
||||
|
||||
# 选择会话
|
||||
use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
||||
|
||||
|
||||
@@ -501,9 +501,6 @@ class WeChatPadEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
),
|
||||
message_chain=message_chain,
|
||||
time=event['create_time'],
|
||||
|
||||
@@ -35,6 +35,20 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
'media_id': await bot.get_media_id(msg),
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.Voice:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'voice',
|
||||
'media_id': await bot.get_media_id(msg),
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.File:
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'file',
|
||||
'media_id': await bot.get_media_id(msg),
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.Forward:
|
||||
for node in msg.node_list:
|
||||
content_list.extend((await WecomMessageConverter.yiri2target(node.message_chain, bot)))
|
||||
@@ -185,6 +199,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
|
||||
elif content['type'] == 'image':
|
||||
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
elif content['type'] == 'voice':
|
||||
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
elif content['type'] == 'file':
|
||||
await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||
@@ -197,6 +215,10 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.bot.send_private_msg(user_id, agent_id, content['content'])
|
||||
if content['type'] == 'image':
|
||||
await self.bot.send_image(user_id, agent_id, content['media'])
|
||||
if content['type'] == 'voice':
|
||||
await self.bot.send_voice(user_id, agent_id, content['media'])
|
||||
if content['type'] == 'file':
|
||||
await self.bot.send_file(user_id, agent_id, content['media'])
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
@@ -232,19 +254,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
return await self.bot.handle_unified_webhook(request)
|
||||
|
||||
async def run_async(self):
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
|
||||
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
|
||||
|
||||
await self.logger.info('企业微信 Webhook 回调地址:')
|
||||
await self.logger.info(f' 本地地址: {webhook_url}')
|
||||
await self.logger.info(f' 公网地址: {webhook_url_public}')
|
||||
await self.logger.info('请在企业微信后台配置此回调地址')
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'无法生成 webhook URL: {e}')
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -21,21 +21,21 @@ spec:
|
||||
- name: secret
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥
|
||||
zh_Hans: 密钥 (Secret)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hans: 令牌 (Token)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: EncodingAESKey
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥
|
||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -28,9 +28,105 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
||||
if event.type == 'group':
|
||||
yiri_msg_list.append(platform_message.At(target=event.ai_bot_id))
|
||||
yiri_msg_list.append(platform_message.Source(id=event.message_id, time=datetime.datetime.now()))
|
||||
yiri_msg_list.append(platform_message.Plain(text=event.content))
|
||||
if event.picurl != '':
|
||||
yiri_msg_list.append(platform_message.Image(base64=event.picurl))
|
||||
|
||||
if event.content:
|
||||
yiri_msg_list.append(platform_message.Plain(text=event.content))
|
||||
|
||||
images = []
|
||||
if event.images:
|
||||
images.extend([img for img in event.images if img])
|
||||
if not images and event.picurl:
|
||||
images.append(event.picurl)
|
||||
for image_base64 in images:
|
||||
if image_base64:
|
||||
yiri_msg_list.append(platform_message.Image(base64=image_base64))
|
||||
|
||||
file_info = event.file or {}
|
||||
if file_info:
|
||||
file_url = (
|
||||
file_info.get('download_url')
|
||||
or file_info.get('url')
|
||||
or file_info.get('fileurl')
|
||||
or file_info.get('path')
|
||||
)
|
||||
file_base64 = file_info.get('base64')
|
||||
file_name = file_info.get('filename') or file_info.get('name')
|
||||
file_size = file_info.get('filesize') or file_info.get('size')
|
||||
file_data = file_url or file_base64
|
||||
if file_data or file_name:
|
||||
file_kwargs = {}
|
||||
if file_data:
|
||||
file_kwargs['url'] = file_data
|
||||
if file_name:
|
||||
file_kwargs['name'] = file_name
|
||||
if file_size is not None:
|
||||
file_kwargs['size'] = file_size
|
||||
try:
|
||||
yiri_msg_list.append(platform_message.File(**file_kwargs))
|
||||
except Exception:
|
||||
# 兜底
|
||||
yiri_msg_list.append(platform_message.Unknown(text='[file message unsupported]'))
|
||||
|
||||
voice_info = event.voice or {}
|
||||
if voice_info:
|
||||
voice_payload = voice_info.get('base64') or voice_info.get('url')
|
||||
if voice_payload:
|
||||
if voice_info.get('base64') and not voice_payload.startswith('data:'):
|
||||
voice_payload = f'data:audio/mpeg;base64,{voice_info.get("base64")}'
|
||||
try:
|
||||
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
|
||||
except Exception:
|
||||
try:
|
||||
voice_kwargs = {'url': voice_payload}
|
||||
voice_name = voice_info.get('filename') or voice_info.get('name')
|
||||
voice_size = voice_info.get('filesize') or voice_info.get('size')
|
||||
if voice_name:
|
||||
voice_kwargs['name'] = voice_name
|
||||
if voice_size is not None:
|
||||
voice_kwargs['size'] = voice_size
|
||||
yiri_msg_list.append(platform_message.File(**voice_kwargs))
|
||||
except Exception:
|
||||
yiri_msg_list.append(platform_message.Unknown(text='[voice message unsupported]'))
|
||||
|
||||
video_info = event.video or {}
|
||||
if video_info:
|
||||
video_payload = (
|
||||
video_info.get('base64')
|
||||
or video_info.get('url')
|
||||
or video_info.get('download_url')
|
||||
or video_info.get('fileurl')
|
||||
)
|
||||
if video_payload:
|
||||
video_kwargs = {'url': video_payload}
|
||||
video_name = video_info.get('filename') or video_info.get('name')
|
||||
video_size = video_info.get('filesize') or video_info.get('size')
|
||||
if video_name:
|
||||
video_kwargs['name'] = video_name
|
||||
if video_size is not None:
|
||||
video_kwargs['size'] = video_size
|
||||
try:
|
||||
# 没有专门的视频类型,沿用 File 传递给上层
|
||||
yiri_msg_list.append(platform_message.File(**video_kwargs))
|
||||
except Exception:
|
||||
yiri_msg_list.append(platform_message.Unknown(text='[video message unsupported]'))
|
||||
|
||||
if event.msgtype == 'link' and event.link:
|
||||
link = event.link
|
||||
summary = '\n'.join(
|
||||
filter(
|
||||
None,
|
||||
[link.get('title', ''), link.get('description') or link.get('digest', ''), link.get('url', '')],
|
||||
)
|
||||
)
|
||||
if summary:
|
||||
yiri_msg_list.append(platform_message.Plain(text=summary))
|
||||
|
||||
has_content_element = any(
|
||||
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
|
||||
)
|
||||
if not has_content_element:
|
||||
fallback_type = event.msgtype or 'unknown'
|
||||
yiri_msg_list.append(platform_message.Unknown(text=f'[unsupported wecom msgtype: {fallback_type}]'))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
@@ -67,9 +163,6 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
join_timestamp=0,
|
||||
last_speak_timestamp=0,
|
||||
mute_time_remaining=0,
|
||||
)
|
||||
time = datetime.datetime.now().timestamp()
|
||||
return platform_events.GroupMessage(
|
||||
@@ -211,20 +304,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
# 打印 webhook 回调地址
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f'http://127.0.0.1:{api_port}/bots/{self.bot_uuid}'
|
||||
webhook_url_public = f'http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}'
|
||||
|
||||
await self.logger.info('企业微信机器人 Webhook 回调地址:')
|
||||
await self.logger.info(f' 本地地址: {webhook_url}')
|
||||
await self.logger.info(f' 公网地址: {webhook_url_public}')
|
||||
await self.logger.info('请在企业微信后台配置此回调地址')
|
||||
except Exception as e:
|
||||
await self.logger.warning(f'无法生成 webhook URL: {e}')
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
@@ -21,14 +21,14 @@ spec:
|
||||
- name: Token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hans: 令牌 (Token)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: EncodingAESKey
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥
|
||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -213,23 +213,10 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
# 打印 webhook 回调地址
|
||||
if self.bot_uuid and hasattr(self.logger, 'ap'):
|
||||
try:
|
||||
api_port = self.logger.ap.instance_config.data['api']['port']
|
||||
webhook_url = f"http://127.0.0.1:{api_port}/bots/{self.bot_uuid}"
|
||||
webhook_url_public = f"http://<Your-Public-IP>:{api_port}/bots/{self.bot_uuid}"
|
||||
|
||||
await self.logger.info(f"企业微信客服 Webhook 回调地址:")
|
||||
await self.logger.info(f" 本地地址: {webhook_url}")
|
||||
await self.logger.info(f" 公网地址: {webhook_url_public}")
|
||||
await self.logger.info(f"请在企业微信后台配置此回调地址")
|
||||
except Exception as e:
|
||||
await self.logger.warning(f"无法生成 webhook URL: {e}")
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
|
||||
@@ -22,12 +22,16 @@ class WebhookPusher:
|
||||
self.ap = ap
|
||||
self.logger = self.ap.logger
|
||||
|
||||
async def push_person_message(self, event: platform_events.FriendMessage, bot_uuid: str, adapter_name: str) -> None:
|
||||
"""Push person message event to webhooks"""
|
||||
async def push_person_message(self, event: platform_events.FriendMessage, bot_uuid: str, adapter_name: str) -> bool:
|
||||
"""Push person message event to webhooks
|
||||
|
||||
Returns:
|
||||
bool: True if any webhook responded with skip_pipeline=true, False otherwise
|
||||
"""
|
||||
try:
|
||||
webhooks = await self.ap.webhook_service.get_enabled_webhooks()
|
||||
if not webhooks:
|
||||
return
|
||||
return False
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
@@ -47,17 +51,30 @@ class WebhookPusher:
|
||||
|
||||
# Push to all webhooks asynchronously
|
||||
tasks = [self._push_to_webhook(webhook['url'], payload) for webhook in webhooks]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Check if any webhook responded with skip_pipeline=true
|
||||
for result in results:
|
||||
if isinstance(result, dict) and result.get('skip_pipeline') is True:
|
||||
self.logger.info(f'Webhook responded with skip_pipeline=true, skipping pipeline for person message')
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to push person message to webhooks: {e}')
|
||||
return False
|
||||
|
||||
async def push_group_message(self, event: platform_events.GroupMessage, bot_uuid: str, adapter_name: str) -> None:
|
||||
"""Push group message event to webhooks"""
|
||||
async def push_group_message(self, event: platform_events.GroupMessage, bot_uuid: str, adapter_name: str) -> bool:
|
||||
"""Push group message event to webhooks
|
||||
|
||||
Returns:
|
||||
bool: True if any webhook responded with skip_pipeline=true, False otherwise
|
||||
"""
|
||||
try:
|
||||
webhooks = await self.ap.webhook_service.get_enabled_webhooks()
|
||||
if not webhooks:
|
||||
return
|
||||
return False
|
||||
|
||||
# Build payload
|
||||
payload = {
|
||||
@@ -81,13 +98,26 @@ class WebhookPusher:
|
||||
|
||||
# Push to all webhooks asynchronously
|
||||
tasks = [self._push_to_webhook(webhook['url'], payload) for webhook in webhooks]
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# Check if any webhook responded with skip_pipeline=true
|
||||
for result in results:
|
||||
if isinstance(result, dict) and result.get('skip_pipeline') is True:
|
||||
self.logger.info(f'Webhook responded with skip_pipeline=true, skipping pipeline for group message')
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f'Failed to push group message to webhooks: {e}')
|
||||
return False
|
||||
|
||||
async def _push_to_webhook(self, url: str, payload: dict) -> None:
|
||||
"""Push payload to a single webhook URL"""
|
||||
async def _push_to_webhook(self, url: str, payload: dict) -> dict | None:
|
||||
"""Push payload to a single webhook URL
|
||||
|
||||
Returns:
|
||||
dict | None: The response JSON if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
@@ -98,9 +128,17 @@ class WebhookPusher:
|
||||
) as response:
|
||||
if response.status >= 400:
|
||||
self.logger.warning(f'Webhook {url} returned status {response.status}')
|
||||
return None
|
||||
else:
|
||||
self.logger.debug(f'Successfully pushed to webhook {url}')
|
||||
try:
|
||||
return await response.json()
|
||||
except Exception as json_error:
|
||||
self.logger.debug(f'Failed to parse JSON response from webhook {url}: {json_error}')
|
||||
return None
|
||||
except asyncio.TimeoutError:
|
||||
self.logger.warning(f'Timeout pushing to webhook {url}')
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Error pushing to webhook {url}: {e}')
|
||||
return None
|
||||
|
||||
@@ -139,6 +139,8 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
|
||||
message_chain_obj = platform_message.MessageChain.model_validate(message_chain)
|
||||
|
||||
self.ap.logger.debug(f'Reply message: {message_chain_obj.model_dump(serialize_as_any=False)}')
|
||||
|
||||
await query.adapter.reply_message(
|
||||
query.message_event,
|
||||
message_chain_obj,
|
||||
@@ -563,7 +565,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
'event_context': event_context,
|
||||
'include_plugins': include_plugins,
|
||||
},
|
||||
timeout=60,
|
||||
timeout=180,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
8
src/langbot/pkg/provider/modelmgr/requesters/seekdb.svg
Normal file
8
src/langbot/pkg/provider/modelmgr/requesters/seekdb.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="24" height="24" rx="5" fill="#1E3A5F"/>
|
||||
<path d="M6 12C6 8.68629 8.68629 6 12 6C15.3137 6 18 8.68629 18 12" stroke="#4FC3F7" stroke-width="2" stroke-linecap="round"/>
|
||||
<path d="M18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12" stroke="#81D4FA" stroke-width="2" stroke-linecap="round"/>
|
||||
<circle cx="12" cy="12" r="2" fill="#4FC3F7"/>
|
||||
<circle cx="6" cy="12" r="1.5" fill="#81D4FA"/>
|
||||
<circle cx="18" cy="12" r="1.5" fill="#4FC3F7"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 569 B |
59
src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.py
Normal file
59
src/langbot/pkg/provider/modelmgr/requesters/seekdbembed.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
from .. import requester
|
||||
|
||||
REQUESTER_NAME: str = 'seekdb-embedding'
|
||||
|
||||
|
||||
class SeekDBEmbedding(requester.ProviderAPIRequester):
|
||||
"""SeekDB built-in embedding requester.
|
||||
|
||||
Uses pyseekdb's local embedding function (all-MiniLM-L6-v2).
|
||||
The base_url config is reserved for future remote embedding support.
|
||||
"""
|
||||
|
||||
default_config: dict[str, typing.Any] = {
|
||||
'base_url': '',
|
||||
}
|
||||
|
||||
_embedding_function = None
|
||||
|
||||
async def initialize(self):
|
||||
try:
|
||||
import pyseekdb
|
||||
except ImportError:
|
||||
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
|
||||
|
||||
self._embedding_function = pyseekdb.get_default_embedding_function()
|
||||
|
||||
async def invoke_llm(
|
||||
self,
|
||||
query,
|
||||
model: requester.RuntimeLLMModel,
|
||||
messages: typing.List,
|
||||
funcs: typing.List = None,
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
remove_think: bool = False,
|
||||
):
|
||||
raise NotImplementedError('SeekDB embedding does not support LLM inference')
|
||||
|
||||
async def invoke_embedding(
|
||||
self,
|
||||
model: requester.RuntimeEmbeddingModel,
|
||||
input_text: typing.List[str],
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> typing.List[typing.List[float]]:
|
||||
"""Generate embeddings using SeekDB's built-in embedding function."""
|
||||
try:
|
||||
if self._embedding_function is None:
|
||||
await self.initialize()
|
||||
|
||||
if self._embedding_function is None:
|
||||
raise RuntimeError("SeekDB embedding function initialization failed")
|
||||
|
||||
return self._embedding_function(input_text)
|
||||
except Exception as e:
|
||||
from .. import errors
|
||||
raise errors.RequesterError(f'SeekDB embedding failed: {str(e)}')
|
||||
@@ -0,0 +1,21 @@
|
||||
apiVersion: v1
|
||||
kind: LLMAPIRequester
|
||||
metadata:
|
||||
name: seekdb-embedding
|
||||
label:
|
||||
en_US: SeekDB Embedding
|
||||
zh_Hans: SeekDB 嵌入
|
||||
description:
|
||||
en_US: SeekDB Python library built-in embedding model (all-MiniLM-L6-v2), it will take time to download the model file for the first time
|
||||
zh_Hans: 使用来自 SeekDB Python 库的内置嵌入模型 (all-MiniLM-L6-v2),首次使用时将会花费时间自动下载模型文件
|
||||
ja_JP: SeekDB Python ライブラリの組み込み埋め込みモデル (all-MiniLM-L6-v2) を使用します。初回使用時にモデルファイルのダウンロードに時間がかかります。
|
||||
icon: seekdb.svg
|
||||
spec:
|
||||
config: []
|
||||
support_type:
|
||||
- text-embedding
|
||||
provider_category: builtin
|
||||
execution:
|
||||
python:
|
||||
path: ./seekdbembed.py
|
||||
attr: SeekDBEmbedding
|
||||
@@ -4,6 +4,7 @@ import typing
|
||||
import json
|
||||
import uuid
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
|
||||
from langbot.pkg.provider import runner
|
||||
@@ -12,6 +13,7 @@ import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
from langbot.pkg.utils import image
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from langbot.libs.dify_service_api.v1 import client, errors
|
||||
import httpx
|
||||
|
||||
|
||||
@runner.runner_class('dify-service-api')
|
||||
@@ -70,14 +72,43 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
content = f'<think>\n{thinking_content}\n</think>\n{content}'.strip()
|
||||
return content, thinking_content
|
||||
|
||||
async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[str]]:
|
||||
"""预处理用户消息,提取纯文本,并将图片上传到 Dify 服务
|
||||
async def _preprocess_user_message(self, query: pipeline_query.Query) -> tuple[str, list[dict]]:
|
||||
"""预处理用户消息,提取纯文本,并将图片/文件上传到 Dify 服务
|
||||
|
||||
Returns:
|
||||
tuple[str, list[str]]: 纯文本和图片的 Dify 服务图片 ID
|
||||
tuple[str, list[dict]]: 纯文本和上传后的文件描述(包含 type 与 id)
|
||||
"""
|
||||
plain_text = ''
|
||||
file_ids = []
|
||||
upload_files: list[dict] = []
|
||||
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||
|
||||
async def upload_file_bytes(file_name: str, file_bytes: bytes, content_type: str) -> str:
|
||||
file_name = file_name or 'file'
|
||||
content_type = content_type or 'application/octet-stream'
|
||||
file = (file_name, file_bytes, content_type)
|
||||
resp = await self.dify_client.upload_file(file, user_tag)
|
||||
return resp['id']
|
||||
|
||||
async def download_file(file_url: str) -> tuple[bytes, str]:
|
||||
"""Download file from url (supports data url)."""
|
||||
|
||||
async with httpx.AsyncClient() as client_session:
|
||||
resp = await client_session.get(file_url)
|
||||
resp.raise_for_status()
|
||||
content_type = (
|
||||
resp.headers.get('content-type') or mimetypes.guess_type(file_url)[0] or 'application/octet-stream'
|
||||
)
|
||||
return resp.content, content_type
|
||||
|
||||
def _detect_file_type(content_type: str) -> str:
|
||||
"""Map MIME to dify file type."""
|
||||
if content_type and content_type.startswith('image/'):
|
||||
return 'image'
|
||||
if content_type and content_type.startswith('audio/'):
|
||||
return 'audio'
|
||||
if content_type and content_type.startswith('video/'):
|
||||
return 'video'
|
||||
return 'document'
|
||||
|
||||
if isinstance(query.user_message.content, list):
|
||||
for ce in query.user_message.content:
|
||||
@@ -86,46 +117,53 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
elif ce.type == 'image_base64':
|
||||
image_b64, image_format = await image.extract_b64_and_format(ce.image_base64)
|
||||
file_bytes = base64.b64decode(image_b64)
|
||||
file = ('img.png', file_bytes, f'image/{image_format}')
|
||||
file_upload_resp = await self.dify_client.upload_file(
|
||||
file,
|
||||
f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
)
|
||||
image_id = file_upload_resp['id']
|
||||
file_ids.append(image_id)
|
||||
# elif ce.type == "file_url":
|
||||
# file_bytes = base64.b64decode(ce.file_url)
|
||||
# file_upload_resp = await self.dify_client.upload_file(
|
||||
# file_bytes,
|
||||
# f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||
# )
|
||||
# file_id = file_upload_resp['id']
|
||||
# file_ids.append(file_id)
|
||||
image_id = await upload_file_bytes(f'img.{image_format}', file_bytes, f'image/{image_format}')
|
||||
upload_files.append({'type': 'image', 'id': image_id})
|
||||
elif ce.type == 'file_url':
|
||||
file_url = getattr(ce, 'file_url', None)
|
||||
file_name = getattr(ce, 'file_name', None) or 'file'
|
||||
try:
|
||||
file_bytes, content_type = await download_file(file_url)
|
||||
file_id = await upload_file_bytes(file_name, file_bytes, content_type)
|
||||
file_type = _detect_file_type(content_type)
|
||||
upload_files.append({'type': file_type, 'id': file_id})
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'dify file upload failed: {e}')
|
||||
elif ce.type == 'file_base64':
|
||||
file_name = getattr(ce, 'file_name', None) or 'file'
|
||||
|
||||
header, b64_data = ce.file_base64.split(',', 1)
|
||||
content_type = 'application/octet-stream'
|
||||
if ';' in header:
|
||||
content_type = header.split(';')[0][5:] or content_type
|
||||
file_bytes = base64.b64decode(b64_data)
|
||||
file_id = await upload_file_bytes(file_name, file_bytes, content_type)
|
||||
file_type = _detect_file_type(content_type)
|
||||
upload_files.append({'type': file_type, 'id': file_id})
|
||||
|
||||
elif isinstance(query.user_message.content, str):
|
||||
plain_text = query.user_message.content
|
||||
# plain_text = "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image." if file_ids and not plain_text else plain_text
|
||||
# plain_text = "The user message type cannot be parsed." if not file_ids and not plain_text else plain_text
|
||||
# plain_text = plain_text if plain_text else "When the file content is readable, please read the content of this file. When the file is an image, describe the content of this image."
|
||||
# print(self.pipeline_config['ai'])
|
||||
|
||||
plain_text = plain_text if plain_text else self.pipeline_config['ai']['dify-service-api']['base-prompt']
|
||||
|
||||
return plain_text, file_ids
|
||||
return plain_text, upload_files
|
||||
|
||||
async def _chat_messages(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用聊天助手"""
|
||||
cov_id = query.session.using_conversation.uuid or ''
|
||||
cov_id = query.session.using_conversation.uuid or None
|
||||
query.variables['conversation_id'] = cov_id
|
||||
|
||||
plain_text, image_ids = await self._preprocess_user_message(query)
|
||||
plain_text, upload_files = await self._preprocess_user_message(query)
|
||||
|
||||
files = [
|
||||
{
|
||||
'type': 'image',
|
||||
'upload_file_id': image_id,
|
||||
'type': f['type'],
|
||||
'transfer_method': 'local_file',
|
||||
'upload_file_id': f['id'],
|
||||
}
|
||||
for image_id in image_ids
|
||||
for f in upload_files
|
||||
]
|
||||
|
||||
mode = 'basic' # 标记是基础编排还是工作流编排
|
||||
@@ -180,18 +218,18 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用聊天助手"""
|
||||
cov_id = query.session.using_conversation.uuid or ''
|
||||
cov_id = query.session.using_conversation.uuid or None
|
||||
query.variables['conversation_id'] = cov_id
|
||||
|
||||
plain_text, image_ids = await self._preprocess_user_message(query)
|
||||
plain_text, upload_files = await self._preprocess_user_message(query)
|
||||
|
||||
files = [
|
||||
{
|
||||
'type': 'image',
|
||||
'type': f['type'],
|
||||
'transfer_method': 'local_file',
|
||||
'upload_file_id': image_id,
|
||||
'upload_file_id': f['id'],
|
||||
}
|
||||
for image_id in image_ids
|
||||
for f in upload_files
|
||||
]
|
||||
|
||||
ignored_events = []
|
||||
@@ -280,15 +318,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
query.variables['conversation_id'] = query.session.using_conversation.uuid
|
||||
|
||||
plain_text, image_ids = await self._preprocess_user_message(query)
|
||||
plain_text, upload_files = await self._preprocess_user_message(query)
|
||||
|
||||
files = [
|
||||
{
|
||||
'type': 'image',
|
||||
'type': f['type'],
|
||||
'transfer_method': 'local_file',
|
||||
'upload_file_id': image_id,
|
||||
'upload_file_id': f['id'],
|
||||
}
|
||||
for image_id in image_ids
|
||||
for f in upload_files
|
||||
]
|
||||
|
||||
ignored_events = ['text_chunk', 'workflow_started']
|
||||
@@ -349,18 +387,18 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
||||
"""调用聊天助手"""
|
||||
cov_id = query.session.using_conversation.uuid or ''
|
||||
cov_id = query.session.using_conversation.uuid or None
|
||||
query.variables['conversation_id'] = cov_id
|
||||
|
||||
plain_text, image_ids = await self._preprocess_user_message(query)
|
||||
plain_text, upload_files = await self._preprocess_user_message(query)
|
||||
|
||||
files = [
|
||||
{
|
||||
'type': 'image',
|
||||
'type': f['type'],
|
||||
'transfer_method': 'local_file',
|
||||
'upload_file_id': image_id,
|
||||
'upload_file_id': f['id'],
|
||||
}
|
||||
for image_id in image_ids
|
||||
for f in upload_files
|
||||
]
|
||||
|
||||
basic_mode_pending_chunk = ''
|
||||
@@ -433,18 +471,18 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
||||
"""调用聊天助手"""
|
||||
cov_id = query.session.using_conversation.uuid or ''
|
||||
cov_id = query.session.using_conversation.uuid or None
|
||||
query.variables['conversation_id'] = cov_id
|
||||
|
||||
plain_text, image_ids = await self._preprocess_user_message(query)
|
||||
plain_text, upload_files = await self._preprocess_user_message(query)
|
||||
|
||||
files = [
|
||||
{
|
||||
'type': 'image',
|
||||
'type': f['type'],
|
||||
'transfer_method': 'local_file',
|
||||
'upload_file_id': image_id,
|
||||
'upload_file_id': f['id'],
|
||||
}
|
||||
for image_id in image_ids
|
||||
for f in upload_files
|
||||
]
|
||||
|
||||
ignored_events = []
|
||||
@@ -558,15 +596,15 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
query.variables['conversation_id'] = query.session.using_conversation.uuid
|
||||
|
||||
plain_text, image_ids = await self._preprocess_user_message(query)
|
||||
plain_text, upload_files = await self._preprocess_user_message(query)
|
||||
|
||||
files = [
|
||||
{
|
||||
'type': 'image',
|
||||
'type': f['type'],
|
||||
'transfer_method': 'local_file',
|
||||
'upload_file_id': image_id,
|
||||
'upload_file_id': f['id'],
|
||||
}
|
||||
for image_id in image_ids
|
||||
for f in upload_files
|
||||
]
|
||||
|
||||
ignored_events = ['workflow_started']
|
||||
|
||||
@@ -94,7 +94,6 @@ class LangflowAPIRunner(runner.RequestRunner):
|
||||
if is_stream:
|
||||
# 流式请求
|
||||
async with client.stream('POST', url, json=payload, headers=headers, timeout=120.0) as response:
|
||||
print(response)
|
||||
response.raise_for_status()
|
||||
|
||||
accumulated_content = ''
|
||||
|
||||
@@ -70,30 +70,88 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
|
||||
async def _process_stream_response(self, response: aiohttp.ClientResponse) -> typing.AsyncGenerator[
|
||||
provider_message.Message, None]:
|
||||
"""处理流式响应"""
|
||||
"""处理流式响应——支持部分 JSON 和多个 JSON 对象在同一 chunk 的情况"""
|
||||
full_content = ""
|
||||
message_idx = 0
|
||||
chunk_idx = 0
|
||||
is_final = False
|
||||
async for chunk in response.content.iter_chunked(1024):
|
||||
if not chunk:
|
||||
message_idx = 0
|
||||
|
||||
buffer = ""
|
||||
decoder = json.JSONDecoder()
|
||||
|
||||
async for raw_chunk in response.content.iter_chunked(1024):
|
||||
if not raw_chunk:
|
||||
continue
|
||||
|
||||
try:
|
||||
data = json.loads(chunk)
|
||||
if data.get('type') == 'item' and 'content' in data:
|
||||
# 将 bytes 解码为字符串(容忍错误)
|
||||
if isinstance(raw_chunk, (bytes, bytearray)):
|
||||
chunk_str = raw_chunk.decode('utf-8', errors='replace')
|
||||
else:
|
||||
chunk_str = str(raw_chunk)
|
||||
|
||||
buffer += chunk_str
|
||||
|
||||
# 尝试从 buffer 中循环解析出 JSON 对象(处理多个对象或部分对象)
|
||||
while buffer:
|
||||
buffer = buffer.lstrip()
|
||||
if not buffer:
|
||||
break
|
||||
try:
|
||||
obj, idx = decoder.raw_decode(buffer)
|
||||
buffer = buffer[idx:]
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
# 忽略非字典类型的顶级 JSON
|
||||
continue
|
||||
|
||||
if obj.get('type') == 'item' and 'content' in obj:
|
||||
chunk_idx += 1
|
||||
content = obj['content']
|
||||
full_content += content
|
||||
elif obj.get('type') == 'end':
|
||||
is_final = True
|
||||
|
||||
if is_final or chunk_idx % 8 == 0:
|
||||
message_idx += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=full_content,
|
||||
is_final=is_final,
|
||||
msg_sequence=message_idx,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
# buffer 末尾可能是一个不完整的 JSON,等待更多数据
|
||||
break
|
||||
except Exception as e:
|
||||
# 记录解析失败并继续接收后续 chunk
|
||||
try:
|
||||
preview = chunk_str[:200]
|
||||
except Exception:
|
||||
preview = '<unavailable>'
|
||||
self.ap.logger.warning(f"Failed to process chunk: {e}; chunk preview: {preview}")
|
||||
|
||||
# 流结束后,尝试解析残余 buffer
|
||||
if buffer:
|
||||
try:
|
||||
buffer = buffer.strip()
|
||||
if buffer:
|
||||
obj, _ = decoder.raw_decode(buffer)
|
||||
if isinstance(obj, dict):
|
||||
if obj.get('type') == 'item' and 'content' in obj:
|
||||
full_content += obj['content']
|
||||
elif obj.get('type') == 'end':
|
||||
is_final = True
|
||||
message_idx += 1
|
||||
content = data['content']
|
||||
full_content += content
|
||||
elif data.get('type') == 'end':
|
||||
is_final = True
|
||||
if is_final or message_idx % 8 == 0:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=full_content,
|
||||
is_final=is_final,
|
||||
msg_sequence=message_idx,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
self.ap.logger.warning(f"Failed to parse final JSON line: {response.text()}")
|
||||
except Exception as e:
|
||||
preview = buffer[:200]
|
||||
self.ap.logger.warning(f"Failed to parse remaining buffer: {e}; buffer preview: {preview}")
|
||||
|
||||
async def _call_webhook(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用n8n webhook"""
|
||||
|
||||
@@ -33,6 +33,7 @@ class SessionManager:
|
||||
session = provider_session.Session(
|
||||
launcher_type=query.launcher_type,
|
||||
launcher_id=query.launcher_id,
|
||||
sender_id=query.sender_id,
|
||||
)
|
||||
session._semaphore = asyncio.Semaphore(session_concurrency)
|
||||
self.session_list.append(session)
|
||||
|
||||
@@ -153,7 +153,9 @@ async def get_qq_image_bytes(image_url: str, query: dict = {}) -> tuple[bytes, s
|
||||
ssl_context.check_hostname = False
|
||||
ssl_context.verify_mode = ssl.CERT_NONE
|
||||
async with aiohttp.ClientSession(trust_env=False) as session:
|
||||
async with session.get(image_url, params=query, ssl=ssl_context) as resp:
|
||||
async with session.get(
|
||||
image_url, params=query, ssl=ssl_context, timeout=aiohttp.ClientTimeout(total=30.0)
|
||||
) as resp:
|
||||
resp.raise_for_status()
|
||||
file_bytes = await resp.read()
|
||||
content_type = resp.headers.get('Content-Type')
|
||||
|
||||
@@ -4,6 +4,9 @@ from ..core import app
|
||||
from .vdb import VectorDatabase
|
||||
from .vdbs.chroma import ChromaVectorDatabase
|
||||
from .vdbs.qdrant import QdrantVectorDatabase
|
||||
from .vdbs.seekdb import SeekDBVectorDatabase
|
||||
from .vdbs.milvus import MilvusVectorDatabase
|
||||
from .vdbs.pgvector_db import PgVectorDatabase
|
||||
|
||||
|
||||
class VectorDBManager:
|
||||
@@ -16,12 +19,50 @@ class VectorDBManager:
|
||||
async def initialize(self):
|
||||
kb_config = self.ap.instance_config.data.get('vdb')
|
||||
if kb_config:
|
||||
if kb_config.get('use') == 'chroma':
|
||||
vdb_type = kb_config.get('use')
|
||||
|
||||
if vdb_type == 'chroma':
|
||||
self.vector_db = ChromaVectorDatabase(self.ap)
|
||||
self.ap.logger.info('Initialized Chroma vector database backend.')
|
||||
elif kb_config.get('use') == 'qdrant':
|
||||
|
||||
elif vdb_type == 'qdrant':
|
||||
self.vector_db = QdrantVectorDatabase(self.ap)
|
||||
self.ap.logger.info('Initialized Qdrant vector database backend.')
|
||||
elif vdb_type == 'seekdb':
|
||||
self.vector_db = SeekDBVectorDatabase(self.ap)
|
||||
self.ap.logger.info('Initialized SeekDB vector database backend.')
|
||||
|
||||
elif vdb_type == 'milvus':
|
||||
# Get Milvus configuration
|
||||
milvus_config = kb_config.get('milvus', {})
|
||||
uri = milvus_config.get('uri', './data/milvus.db')
|
||||
token = milvus_config.get('token')
|
||||
self.vector_db = MilvusVectorDatabase(self.ap, uri=uri, token=token)
|
||||
self.ap.logger.info('Initialized Milvus vector database backend.')
|
||||
|
||||
elif vdb_type == 'pgvector':
|
||||
# Get pgvector configuration
|
||||
pgvector_config = kb_config.get('pgvector', {})
|
||||
connection_string = pgvector_config.get('connection_string')
|
||||
if connection_string:
|
||||
self.vector_db = PgVectorDatabase(self.ap, connection_string=connection_string)
|
||||
else:
|
||||
# Use individual parameters
|
||||
host = pgvector_config.get('host', 'localhost')
|
||||
port = pgvector_config.get('port', 5432)
|
||||
database = pgvector_config.get('database', 'langbot')
|
||||
user = pgvector_config.get('user', 'postgres')
|
||||
password = pgvector_config.get('password', 'postgres')
|
||||
self.vector_db = PgVectorDatabase(
|
||||
self.ap,
|
||||
host=host,
|
||||
port=port,
|
||||
database=database,
|
||||
user=user,
|
||||
password=password
|
||||
)
|
||||
self.ap.logger.info('Initialized pgvector database backend.')
|
||||
|
||||
else:
|
||||
self.vector_db = ChromaVectorDatabase(self.ap)
|
||||
self.ap.logger.warning('No valid vector database backend configured, defaulting to Chroma.')
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Vector database implementations for LangBot."""
|
||||
|
||||
from .chroma import ChromaVectorDatabase
|
||||
from .qdrant import QdrantVectorDatabase
|
||||
from .seekdb import SeekDBVectorDatabase
|
||||
|
||||
__all__ = ['ChromaVectorDatabase', 'QdrantVectorDatabase', 'SeekDBVectorDatabase']
|
||||
|
||||
249
src/langbot/pkg/vector/vdbs/milvus.py
Normal file
249
src/langbot/pkg/vector/vdbs/milvus.py
Normal file
@@ -0,0 +1,249 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import Any, Dict
|
||||
from pymilvus import MilvusClient, DataType
|
||||
from langbot.pkg.vector.vdb import VectorDatabase
|
||||
from langbot.pkg.core import app
|
||||
|
||||
|
||||
class MilvusVectorDatabase(VectorDatabase):
|
||||
"""Milvus vector database implementation"""
|
||||
|
||||
def __init__(self, ap: app.Application, uri: str = "milvus.db", token: str = None):
|
||||
"""Initialize Milvus vector database
|
||||
|
||||
Args:
|
||||
ap: Application instance
|
||||
uri: Milvus connection URI. For local file: "milvus.db"
|
||||
For remote server: "http://localhost:19530"
|
||||
token: Optional authentication token for remote connections
|
||||
"""
|
||||
self.ap = ap
|
||||
self.uri = uri
|
||||
self.token = token
|
||||
self.client = None
|
||||
self._collections = {}
|
||||
self._initialize_client()
|
||||
|
||||
def _initialize_client(self):
|
||||
"""Initialize Milvus client connection"""
|
||||
try:
|
||||
if self.token:
|
||||
self.client = MilvusClient(uri=self.uri, token=self.token)
|
||||
else:
|
||||
self.client = MilvusClient(uri=self.uri)
|
||||
self.ap.logger.info(f"Connected to Milvus at {self.uri}")
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f"Failed to connect to Milvus: {e}")
|
||||
raise
|
||||
|
||||
async def get_or_create_collection(self, collection: str):
|
||||
"""Get or create a Milvus collection
|
||||
|
||||
Args:
|
||||
collection: Collection name (corresponds to knowledge base UUID)
|
||||
"""
|
||||
if collection in self._collections:
|
||||
return self._collections[collection]
|
||||
|
||||
# Check if collection exists
|
||||
has_collection = await asyncio.to_thread(
|
||||
self.client.has_collection, collection_name=collection
|
||||
)
|
||||
|
||||
if not has_collection:
|
||||
# Create collection with custom schema to support string IDs
|
||||
from pymilvus import CollectionSchema, FieldSchema, DataType
|
||||
|
||||
fields = [
|
||||
FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=255),
|
||||
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=1536),
|
||||
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
|
||||
FieldSchema(name="file_id", dtype=DataType.VARCHAR, max_length=255),
|
||||
FieldSchema(name="chunk_uuid", dtype=DataType.VARCHAR, max_length=255),
|
||||
]
|
||||
|
||||
schema = CollectionSchema(fields=fields, description="LangBot knowledge base vectors")
|
||||
|
||||
await asyncio.to_thread(
|
||||
self.client.create_collection,
|
||||
collection_name=collection,
|
||||
schema=schema,
|
||||
metric_type="COSINE",
|
||||
)
|
||||
|
||||
# Create index for vector field (required for loading/searching)
|
||||
index_params = {
|
||||
"metric_type": "COSINE",
|
||||
"index_type": "AUTOINDEX",
|
||||
"params": {}
|
||||
}
|
||||
await asyncio.to_thread(
|
||||
self.client.create_index,
|
||||
collection_name=collection,
|
||||
field_name="vector",
|
||||
index_params=index_params
|
||||
)
|
||||
|
||||
self.ap.logger.info(f"Created Milvus collection '{collection}' with index")
|
||||
else:
|
||||
self.ap.logger.info(f"Milvus collection '{collection}' already exists")
|
||||
|
||||
self._collections[collection] = collection
|
||||
return collection
|
||||
|
||||
async def add_embeddings(
|
||||
self,
|
||||
collection: str,
|
||||
ids: list[str],
|
||||
embeddings_list: list[list[float]],
|
||||
metadatas: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Add vector embeddings to Milvus collection
|
||||
|
||||
Args:
|
||||
collection: Collection name
|
||||
ids: List of unique IDs for each vector
|
||||
embeddings_list: List of embedding vectors
|
||||
metadatas: List of metadata dictionaries for each vector
|
||||
"""
|
||||
await self.get_or_create_collection(collection)
|
||||
|
||||
# Prepare data in Milvus format
|
||||
data = []
|
||||
for i, vector_id in enumerate(ids):
|
||||
entry = {
|
||||
"id": vector_id,
|
||||
"vector": embeddings_list[i],
|
||||
}
|
||||
# Add metadata fields
|
||||
if metadatas and i < len(metadatas):
|
||||
metadata = metadatas[i]
|
||||
# Add common metadata fields
|
||||
if "text" in metadata:
|
||||
entry["text"] = metadata["text"]
|
||||
if "file_id" in metadata:
|
||||
entry["file_id"] = metadata["file_id"]
|
||||
if "uuid" in metadata:
|
||||
entry["chunk_uuid"] = metadata["uuid"]
|
||||
data.append(entry)
|
||||
|
||||
# Insert data into Milvus
|
||||
await asyncio.to_thread(
|
||||
self.client.insert,
|
||||
collection_name=collection,
|
||||
data=data
|
||||
)
|
||||
|
||||
# Load collection for searching (Milvus requires this)
|
||||
await asyncio.to_thread(
|
||||
self.client.load_collection,
|
||||
collection_name=collection
|
||||
)
|
||||
|
||||
self.ap.logger.info(f"Added {len(ids)} embeddings to Milvus collection '{collection}'")
|
||||
|
||||
async def search(
|
||||
self, collection: str, query_embedding: list[float], k: int = 5
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for similar vectors in Milvus collection
|
||||
|
||||
Args:
|
||||
collection: Collection name
|
||||
query_embedding: Query vector
|
||||
k: Number of top results to return
|
||||
|
||||
Returns:
|
||||
Dictionary with search results in Chroma-compatible format
|
||||
"""
|
||||
await self.get_or_create_collection(collection)
|
||||
|
||||
# Perform search
|
||||
search_params = {
|
||||
"metric_type": "COSINE",
|
||||
"params": {}
|
||||
}
|
||||
|
||||
results = await asyncio.to_thread(
|
||||
self.client.search,
|
||||
collection_name=collection,
|
||||
data=[query_embedding],
|
||||
limit=k,
|
||||
search_params=search_params,
|
||||
output_fields=["text", "file_id", "chunk_uuid"]
|
||||
)
|
||||
|
||||
# Convert results to Chroma-compatible format
|
||||
# Milvus returns: [[ {id, distance, entity: {...}} ]]
|
||||
ids = []
|
||||
distances = []
|
||||
metadatas = []
|
||||
|
||||
if results and len(results) > 0:
|
||||
for hit in results[0]:
|
||||
ids.append(hit.get("id", ""))
|
||||
distances.append(hit.get("distance", 0.0))
|
||||
|
||||
# Build metadata from entity fields
|
||||
entity = hit.get("entity", {})
|
||||
metadata = {}
|
||||
if "text" in entity:
|
||||
metadata["text"] = entity["text"]
|
||||
if "file_id" in entity:
|
||||
metadata["file_id"] = entity["file_id"]
|
||||
if "chunk_uuid" in entity:
|
||||
metadata["uuid"] = entity["chunk_uuid"]
|
||||
metadatas.append(metadata)
|
||||
|
||||
# Return in Chroma-compatible format (nested lists)
|
||||
result = {
|
||||
"ids": [ids],
|
||||
"distances": [distances],
|
||||
"metadatas": [metadatas]
|
||||
}
|
||||
|
||||
self.ap.logger.info(
|
||||
f"Milvus search in '{collection}' returned {len(ids)} results"
|
||||
)
|
||||
return result
|
||||
|
||||
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
|
||||
"""Delete vectors from collection by file_id
|
||||
|
||||
Args:
|
||||
collection: Collection name
|
||||
file_id: File ID to filter deletion
|
||||
"""
|
||||
await self.get_or_create_collection(collection)
|
||||
|
||||
# Delete entities matching the file_id
|
||||
await asyncio.to_thread(
|
||||
self.client.delete,
|
||||
collection_name=collection,
|
||||
filter=f'file_id == "{file_id}"'
|
||||
)
|
||||
self.ap.logger.info(
|
||||
f"Deleted embeddings from Milvus collection '{collection}' with file_id: {file_id}"
|
||||
)
|
||||
|
||||
async def delete_collection(self, collection: str):
|
||||
"""Delete a Milvus collection
|
||||
|
||||
Args:
|
||||
collection: Collection name to delete
|
||||
"""
|
||||
if collection in self._collections:
|
||||
del self._collections[collection]
|
||||
|
||||
# Check if collection exists before attempting deletion
|
||||
has_collection = await asyncio.to_thread(
|
||||
self.client.has_collection, collection_name=collection
|
||||
)
|
||||
|
||||
if has_collection:
|
||||
await asyncio.to_thread(
|
||||
self.client.drop_collection, collection_name=collection
|
||||
)
|
||||
self.ap.logger.info(f"Deleted Milvus collection '{collection}'")
|
||||
else:
|
||||
self.ap.logger.warning(f"Milvus collection '{collection}' not found")
|
||||
286
src/langbot/pkg/vector/vdbs/pgvector_db.py
Normal file
286
src/langbot/pkg/vector/vdbs/pgvector_db.py
Normal file
@@ -0,0 +1,286 @@
|
||||
from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import Any, Dict
|
||||
from sqlalchemy import create_engine, text, Column, String, Text
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker, Session
|
||||
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
||||
from pgvector.sqlalchemy import Vector
|
||||
from langbot.pkg.vector.vdb import VectorDatabase
|
||||
from langbot.pkg.core import app
|
||||
import uuid
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class PgVectorEntry(Base):
|
||||
"""SQLAlchemy model for pgvector entries"""
|
||||
__tablename__ = 'langbot_vectors'
|
||||
|
||||
id = Column(String, primary_key=True)
|
||||
collection = Column(String, index=True, nullable=False)
|
||||
embedding = Column(Vector(1536)) # Default dimension, will be created dynamically
|
||||
text = Column(Text)
|
||||
file_id = Column(String, index=True)
|
||||
chunk_uuid = Column(String)
|
||||
|
||||
|
||||
class PgVectorDatabase(VectorDatabase):
|
||||
"""PostgreSQL with pgvector extension database implementation"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ap: app.Application,
|
||||
connection_string: str = None,
|
||||
host: str = "localhost",
|
||||
port: int = 5432,
|
||||
database: str = "langbot",
|
||||
user: str = "postgres",
|
||||
password: str = "postgres"
|
||||
):
|
||||
"""Initialize pgvector database
|
||||
|
||||
Args:
|
||||
ap: Application instance
|
||||
connection_string: Full PostgreSQL connection string (overrides other params)
|
||||
host: PostgreSQL host
|
||||
port: PostgreSQL port
|
||||
database: Database name
|
||||
user: Database user
|
||||
password: Database password
|
||||
"""
|
||||
self.ap = ap
|
||||
|
||||
# Build connection string if not provided
|
||||
if connection_string:
|
||||
self.connection_string = connection_string
|
||||
else:
|
||||
self.connection_string = (
|
||||
f"postgresql+psycopg://{user}:{password}@{host}:{port}/{database}"
|
||||
)
|
||||
|
||||
self.async_connection_string = self.connection_string.replace(
|
||||
"postgresql://", "postgresql+asyncpg://"
|
||||
).replace(
|
||||
"postgresql+psycopg://", "postgresql+asyncpg://"
|
||||
)
|
||||
|
||||
self.engine = None
|
||||
self.async_engine = None
|
||||
self.SessionLocal = None
|
||||
self.AsyncSessionLocal = None
|
||||
self._collections = set()
|
||||
self._initialize_db()
|
||||
|
||||
def _initialize_db(self):
|
||||
"""Initialize database connection and create tables"""
|
||||
try:
|
||||
# Create async engine for async operations
|
||||
self.async_engine = create_async_engine(
|
||||
self.async_connection_string,
|
||||
echo=False,
|
||||
pool_pre_ping=True
|
||||
)
|
||||
self.AsyncSessionLocal = async_sessionmaker(
|
||||
self.async_engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False
|
||||
)
|
||||
|
||||
# Create sync engine for table creation
|
||||
sync_connection_string = self.connection_string.replace(
|
||||
"postgresql+asyncpg://", "postgresql+psycopg://"
|
||||
)
|
||||
self.engine = create_engine(sync_connection_string, echo=False)
|
||||
|
||||
# Create pgvector extension and tables
|
||||
with self.engine.connect() as conn:
|
||||
# Enable pgvector extension
|
||||
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
||||
conn.commit()
|
||||
|
||||
# Create tables
|
||||
Base.metadata.create_all(self.engine)
|
||||
|
||||
self.ap.logger.info(f"Connected to PostgreSQL with pgvector")
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f"Failed to connect to PostgreSQL: {e}")
|
||||
raise
|
||||
|
||||
async def get_or_create_collection(self, collection: str):
|
||||
"""Get or create a collection (logical grouping in pgvector)
|
||||
|
||||
Args:
|
||||
collection: Collection name (knowledge base UUID)
|
||||
"""
|
||||
# In pgvector, collections are logical - we just track them
|
||||
if collection not in self._collections:
|
||||
self._collections.add(collection)
|
||||
self.ap.logger.info(f"Registered pgvector collection '{collection}'")
|
||||
return collection
|
||||
|
||||
async def add_embeddings(
|
||||
self,
|
||||
collection: str,
|
||||
ids: list[str],
|
||||
embeddings_list: list[list[float]],
|
||||
metadatas: list[dict[str, Any]],
|
||||
) -> None:
|
||||
"""Add vector embeddings to pgvector
|
||||
|
||||
Args:
|
||||
collection: Collection name
|
||||
ids: List of unique IDs for each vector
|
||||
embeddings_list: List of embedding vectors
|
||||
metadatas: List of metadata dictionaries
|
||||
"""
|
||||
await self.get_or_create_collection(collection)
|
||||
|
||||
async with self.AsyncSessionLocal() as session:
|
||||
try:
|
||||
for i, vector_id in enumerate(ids):
|
||||
metadata = metadatas[i] if i < len(metadatas) else {}
|
||||
|
||||
entry = PgVectorEntry(
|
||||
id=vector_id,
|
||||
collection=collection,
|
||||
embedding=embeddings_list[i],
|
||||
text=metadata.get("text", ""),
|
||||
file_id=metadata.get("file_id", ""),
|
||||
chunk_uuid=metadata.get("uuid", "")
|
||||
)
|
||||
session.add(entry)
|
||||
|
||||
await session.commit()
|
||||
self.ap.logger.info(
|
||||
f"Added {len(ids)} embeddings to pgvector collection '{collection}'"
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
self.ap.logger.error(f"Error adding embeddings to pgvector: {e}")
|
||||
raise
|
||||
|
||||
async def search(
|
||||
self, collection: str, query_embedding: list[float], k: int = 5
|
||||
) -> Dict[str, Any]:
|
||||
"""Search for similar vectors using cosine distance
|
||||
|
||||
Args:
|
||||
collection: Collection name
|
||||
query_embedding: Query vector
|
||||
k: Number of top results to return
|
||||
|
||||
Returns:
|
||||
Dictionary with search results in Chroma-compatible format
|
||||
"""
|
||||
await self.get_or_create_collection(collection)
|
||||
|
||||
async with self.AsyncSessionLocal() as session:
|
||||
try:
|
||||
# Use cosine distance for similarity search
|
||||
from sqlalchemy import select, func
|
||||
|
||||
# Query for similar vectors
|
||||
stmt = (
|
||||
select(
|
||||
PgVectorEntry.id,
|
||||
PgVectorEntry.text,
|
||||
PgVectorEntry.file_id,
|
||||
PgVectorEntry.chunk_uuid,
|
||||
PgVectorEntry.embedding.cosine_distance(query_embedding).label('distance')
|
||||
)
|
||||
.filter(PgVectorEntry.collection == collection)
|
||||
.order_by(PgVectorEntry.embedding.cosine_distance(query_embedding))
|
||||
.limit(k)
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
rows = result.fetchall()
|
||||
|
||||
# Convert to Chroma-compatible format
|
||||
ids = []
|
||||
distances = []
|
||||
metadatas = []
|
||||
|
||||
for row in rows:
|
||||
ids.append(row.id)
|
||||
distances.append(float(row.distance))
|
||||
metadatas.append({
|
||||
"text": row.text or "",
|
||||
"file_id": row.file_id or "",
|
||||
"uuid": row.chunk_uuid or ""
|
||||
})
|
||||
|
||||
result_dict = {
|
||||
"ids": [ids],
|
||||
"distances": [distances],
|
||||
"metadatas": [metadatas]
|
||||
}
|
||||
|
||||
self.ap.logger.info(
|
||||
f"pgvector search in '{collection}' returned {len(ids)} results"
|
||||
)
|
||||
return result_dict
|
||||
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f"Error searching pgvector: {e}")
|
||||
raise
|
||||
|
||||
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
|
||||
"""Delete vectors by file_id
|
||||
|
||||
Args:
|
||||
collection: Collection name
|
||||
file_id: File ID to filter deletion
|
||||
"""
|
||||
await self.get_or_create_collection(collection)
|
||||
|
||||
async with self.AsyncSessionLocal() as session:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
|
||||
stmt = delete(PgVectorEntry).where(
|
||||
PgVectorEntry.collection == collection,
|
||||
PgVectorEntry.file_id == file_id
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
self.ap.logger.info(
|
||||
f"Deleted embeddings from pgvector collection '{collection}' with file_id: {file_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
self.ap.logger.error(f"Error deleting from pgvector: {e}")
|
||||
raise
|
||||
|
||||
async def delete_collection(self, collection: str):
|
||||
"""Delete all vectors in a collection
|
||||
|
||||
Args:
|
||||
collection: Collection name to delete
|
||||
"""
|
||||
if collection in self._collections:
|
||||
self._collections.remove(collection)
|
||||
|
||||
async with self.AsyncSessionLocal() as session:
|
||||
try:
|
||||
from sqlalchemy import delete
|
||||
|
||||
stmt = delete(PgVectorEntry).where(
|
||||
PgVectorEntry.collection == collection
|
||||
)
|
||||
await session.execute(stmt)
|
||||
await session.commit()
|
||||
|
||||
self.ap.logger.info(f"Deleted pgvector collection '{collection}'")
|
||||
except Exception as e:
|
||||
await session.rollback()
|
||||
self.ap.logger.error(f"Error deleting pgvector collection: {e}")
|
||||
raise
|
||||
|
||||
async def close(self):
|
||||
"""Close database connections"""
|
||||
if self.async_engine:
|
||||
await self.async_engine.dispose()
|
||||
if self.engine:
|
||||
self.engine.dispose()
|
||||
252
src/langbot/pkg/vector/vdbs/seekdb.py
Normal file
252
src/langbot/pkg/vector/vdbs/seekdb.py
Normal file
@@ -0,0 +1,252 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from langbot.pkg.core import app
|
||||
from langbot.pkg.entity.persistence import model as persistence_model
|
||||
from langbot.pkg.vector.vdb import VectorDatabase
|
||||
|
||||
try:
|
||||
import pyseekdb
|
||||
from pyseekdb import HNSWConfiguration
|
||||
|
||||
SEEKDB_AVAILABLE = True
|
||||
except ImportError:
|
||||
SEEKDB_AVAILABLE = False
|
||||
|
||||
SEEKDB_EMBEDDING_MODEL_UUID = 'seekdb-builtin-embedding'
|
||||
SEEKDB_EMBEDDING_REQUESTER = 'seekdb-embedding'
|
||||
|
||||
|
||||
class SeekDBVectorDatabase(VectorDatabase):
|
||||
"""SeekDB vector database adapter for LangBot.
|
||||
|
||||
SeekDB is an AI-native search database by OceanBase that unifies
|
||||
relational, vector, text, JSON and GIS in a single engine.
|
||||
|
||||
Supports both embedded mode and remote server mode.
|
||||
"""
|
||||
|
||||
def __init__(self, ap: app.Application):
|
||||
if not SEEKDB_AVAILABLE:
|
||||
raise ImportError('pyseekdb is not installed. Install it with: pip install pyseekdb')
|
||||
|
||||
self.ap = ap
|
||||
config = self.ap.instance_config.data['vdb']['seekdb']
|
||||
|
||||
# Determine connection mode based on config
|
||||
mode = config.get('mode', 'embedded') # 'embedded' or 'server'
|
||||
|
||||
if mode == 'embedded':
|
||||
# Embedded mode: local database
|
||||
path = config.get('path', './data/seekdb')
|
||||
database = config.get('database', 'langbot')
|
||||
|
||||
# Use AdminClient for database management operations
|
||||
admin_client = pyseekdb.AdminClient(path=path)
|
||||
# Check if database exists using public API
|
||||
existing_dbs = [db.name for db in admin_client.list_databases()]
|
||||
if database not in existing_dbs:
|
||||
# Use public API to create database
|
||||
admin_client.create_database(database)
|
||||
self.ap.logger.info(f"Created SeekDB database '{database}'")
|
||||
|
||||
self.client = pyseekdb.Client(path=path, database=database)
|
||||
self.ap.logger.info(f"Initialized SeekDB in embedded mode at '{path}', database '{database}'")
|
||||
elif mode == 'server':
|
||||
# Server mode: remote SeekDB or OceanBase server
|
||||
host = config.get('host', 'localhost')
|
||||
port = config.get('port', 2881)
|
||||
database = config.get('database', 'langbot')
|
||||
user = config.get('user', 'root')
|
||||
password = config.get('password', '')
|
||||
tenant = config.get('tenant', None) # Optional, for OceanBase
|
||||
|
||||
connection_params = {
|
||||
'host': host,
|
||||
'port': int(port),
|
||||
'database': database,
|
||||
'user': user,
|
||||
'password': password,
|
||||
}
|
||||
|
||||
if tenant:
|
||||
connection_params['tenant'] = tenant
|
||||
|
||||
self.client = pyseekdb.Client(**connection_params)
|
||||
self.ap.logger.info(
|
||||
f"Initialized SeekDB in server mode: {host}:{port}, database '{database}'"
|
||||
+ (f", tenant '{tenant}'" if tenant else '')
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid SeekDB mode: {mode}. Must be 'embedded' or 'server'")
|
||||
|
||||
self._collections: Dict[str, Any] = {}
|
||||
self._collection_configs: Dict[str, HNSWConfiguration] = {}
|
||||
|
||||
self._escape_table = str.maketrans({
|
||||
'\x00': '',
|
||||
'\\': '\\\\',
|
||||
'"': '\\"',
|
||||
'\n': '\\n',
|
||||
'\r': '\\r',
|
||||
'\t': '\\t',
|
||||
})
|
||||
|
||||
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None) -> Any:
|
||||
"""Internal method to get or create a collection with proper configuration."""
|
||||
if collection in self._collections:
|
||||
return self._collections[collection]
|
||||
|
||||
# Check if collection exists
|
||||
if await asyncio.to_thread(self.client.has_collection, collection):
|
||||
# Collection exists, get it
|
||||
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
|
||||
self._collections[collection] = coll
|
||||
self.ap.logger.info(f"SeekDB collection '{collection}' retrieved.")
|
||||
return coll
|
||||
|
||||
# Collection doesn't exist, create it
|
||||
if vector_size is None:
|
||||
# Default dimension if not specified
|
||||
vector_size = 384
|
||||
|
||||
# Create HNSW configuration
|
||||
config = HNSWConfiguration(dimension=vector_size, distance='cosine')
|
||||
self._collection_configs[collection] = config
|
||||
|
||||
# Create collection without embedding function (we manage embeddings externally)
|
||||
coll = await asyncio.to_thread(
|
||||
self.client.create_collection,
|
||||
name=collection,
|
||||
configuration=config,
|
||||
embedding_function=None, # Disable automatic embedding
|
||||
)
|
||||
|
||||
self._collections[collection] = coll
|
||||
self.ap.logger.info(f"SeekDB collection '{collection}' created with dimension={vector_size}, distance='cosine'")
|
||||
return coll
|
||||
|
||||
def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""SeekDB metadata doesn't support \\ and ", insert will error 3104"""
|
||||
return {
|
||||
k: v.translate(self._escape_table) if isinstance(v, str)
|
||||
else v if v is None or isinstance(v, (int, float, bool))
|
||||
else str(v)
|
||||
for k, v in meta.items()
|
||||
if v is not None
|
||||
}
|
||||
|
||||
async def get_or_create_collection(self, collection: str):
|
||||
"""Get or create collection (without vector size - will use default)."""
|
||||
return await self._get_or_create_collection_internal(collection)
|
||||
|
||||
async def add_embeddings(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str],
|
||||
embeddings_list: List[List[float]],
|
||||
metadatas: List[Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Add vector embeddings to the specified collection.
|
||||
|
||||
Args:
|
||||
collection: Collection name
|
||||
ids: List of document IDs
|
||||
embeddings_list: List of embedding vectors
|
||||
metadatas: List of metadata dictionaries
|
||||
"""
|
||||
if not embeddings_list:
|
||||
return
|
||||
|
||||
# Ensure collection exists with correct dimension
|
||||
vector_size = len(embeddings_list[0])
|
||||
coll = await self._get_or_create_collection_internal(collection, vector_size)
|
||||
|
||||
cleaned_metadatas = [self._clean_metadata(meta) for meta in metadatas]
|
||||
|
||||
await asyncio.to_thread(coll.add, ids=ids, embeddings=embeddings_list, metadatas=cleaned_metadatas)
|
||||
|
||||
self.ap.logger.info(f"Added {len(ids)} embeddings to SeekDB collection '{collection}'")
|
||||
|
||||
async def search(self, collection: str, query_embedding: List[float], k: int = 5) -> Dict[str, Any]:
|
||||
"""Search for the most similar vectors in the specified collection.
|
||||
|
||||
Args:
|
||||
collection: Collection name
|
||||
query_embedding: Query vector
|
||||
k: Number of results to return
|
||||
|
||||
Returns:
|
||||
Dictionary with 'ids', 'metadatas', 'distances' keys
|
||||
"""
|
||||
# Check if collection exists
|
||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||
if not exists:
|
||||
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]]}
|
||||
|
||||
# Get collection
|
||||
if collection not in self._collections:
|
||||
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
|
||||
self._collections[collection] = coll
|
||||
else:
|
||||
coll = self._collections[collection]
|
||||
|
||||
# Perform query
|
||||
# SeekDB's query() returns: {'ids': [[...]], 'metadatas': [[...]], 'distances': [[...]]}
|
||||
results = await asyncio.to_thread(coll.query, query_embeddings=query_embedding, n_results=k)
|
||||
|
||||
self.ap.logger.info(f"SeekDB search in '{collection}' returned {len(results.get('ids', [[]])[0])} results")
|
||||
|
||||
return results
|
||||
|
||||
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
|
||||
"""Delete vectors from the collection by file_id metadata.
|
||||
|
||||
Args:
|
||||
collection: Collection name
|
||||
file_id: File ID to delete
|
||||
"""
|
||||
# Check if collection exists
|
||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||
if not exists:
|
||||
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
|
||||
return
|
||||
|
||||
# Get collection
|
||||
if collection not in self._collections:
|
||||
coll = await asyncio.to_thread(self.client.get_collection, collection, embedding_function=None)
|
||||
self._collections[collection] = coll
|
||||
else:
|
||||
coll = self._collections[collection]
|
||||
|
||||
# SeekDB's delete() expects a where clause for filtering
|
||||
# Delete all records where metadata['file_id'] == file_id
|
||||
await asyncio.to_thread(coll.delete, where={'file_id': file_id})
|
||||
|
||||
self.ap.logger.info(f"Deleted embeddings from SeekDB collection '{collection}' with file_id: {file_id}")
|
||||
|
||||
async def delete_collection(self, collection: str):
|
||||
"""Delete the entire collection.
|
||||
|
||||
Args:
|
||||
collection: Collection name
|
||||
"""
|
||||
# Remove from cache
|
||||
if collection in self._collections:
|
||||
del self._collections[collection]
|
||||
if collection in self._collection_configs:
|
||||
del self._collection_configs[collection]
|
||||
|
||||
# Check if collection exists
|
||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||
if not exists:
|
||||
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
|
||||
return
|
||||
|
||||
# Delete collection
|
||||
await asyncio.to_thread(self.client.delete_collection, collection)
|
||||
self.ap.logger.info(f"SeekDB collection '{collection}' deleted")
|
||||
@@ -16,6 +16,7 @@ proxy:
|
||||
https: ''
|
||||
system:
|
||||
recovery_key: ''
|
||||
allow_change_password: true
|
||||
jwt:
|
||||
expire: 604800
|
||||
secret: ''
|
||||
@@ -36,6 +37,26 @@ vdb:
|
||||
host: localhost
|
||||
port: 6333
|
||||
api_key: ''
|
||||
seekdb:
|
||||
mode: embedded # 'embedded' or 'server'
|
||||
# Embedded mode options:
|
||||
path: './data/seekdb'
|
||||
database: 'langbot'
|
||||
# Server mode options (used when mode='server'):
|
||||
host: 'localhost'
|
||||
port: 2881
|
||||
user: 'root'
|
||||
password: ''
|
||||
tenant: '' # Optional, for OceanBase server
|
||||
milvus:
|
||||
uri: 'http://127.0.0.1:19530'
|
||||
token: ''
|
||||
pgvector:
|
||||
host: '127.0.0.1'
|
||||
port: 5433
|
||||
database: 'langbot'
|
||||
user: 'postgres'
|
||||
password: 'postgres'
|
||||
storage:
|
||||
use: local
|
||||
s3:
|
||||
@@ -49,4 +70,4 @@ plugin:
|
||||
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||
enable_marketplace: true
|
||||
cloud_service_url: 'https://space.langbot.app'
|
||||
display_plugin_debug_url: 'http://localhost:5401'
|
||||
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
||||
|
||||
@@ -14,6 +14,8 @@ from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
from langbot.pkg.pipeline import entities as pipeline_entities
|
||||
@@ -159,12 +161,18 @@ def sample_message_chain():
|
||||
|
||||
@pytest.fixture
|
||||
def sample_message_event(sample_message_chain):
|
||||
"""Provides sample message event"""
|
||||
event = Mock()
|
||||
event.sender = Mock()
|
||||
event.sender.id = 12345
|
||||
event.time = 1609459200 # 2021-01-01 00:00:00
|
||||
return event
|
||||
"""Provides sample message event (FriendMessage)"""
|
||||
sender = platform_entities.Friend(
|
||||
id=12345,
|
||||
nickname='TestUser',
|
||||
remark=None,
|
||||
)
|
||||
return platform_events.FriendMessage(
|
||||
type='FriendMessage',
|
||||
sender=sender,
|
||||
message_chain=sample_message_chain,
|
||||
time=1609459200, # 2021-01-01 00:00:00
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
2
web/.gitignore
vendored
2
web/.gitignore
vendored
@@ -40,5 +40,3 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
pnpm-lock.yaml
|
||||
10394
web/package-lock.json
generated
10394
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -51,11 +51,11 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"lodash": "^4.17.21",
|
||||
"lucide-react": "^0.507.0",
|
||||
"next": "15.4.7",
|
||||
"next": "~15.5.9",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.3",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
@@ -82,8 +82,8 @@
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/ms": "^2.1.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/react": "~19.2.7",
|
||||
"@types/react-dom": "~19.2.3",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/unist": "^3.0.3",
|
||||
"eslint": "^9",
|
||||
|
||||
8866
web/pnpm-lock.yaml
generated
Normal file
8866
web/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -111,14 +111,41 @@ export default function BotForm({
|
||||
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
||||
IDynamicFormItemSchema[]
|
||||
>([]);
|
||||
const [filteredDynamicFormConfigList, setFilteredDynamicFormConfigList] =
|
||||
useState<IDynamicFormItemSchema[]>([]);
|
||||
const [, setIsLoading] = useState<boolean>(false);
|
||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
||||
const webhookInputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
// Watch adapter and adapter_config for filtering
|
||||
const currentAdapter = form.watch('adapter');
|
||||
const currentAdapterConfig = form.watch('adapter_config');
|
||||
|
||||
useEffect(() => {
|
||||
setBotFormValues();
|
||||
}, []);
|
||||
|
||||
// Filter dynamic form config list based on enable-webhook status for Lark adapter
|
||||
useEffect(() => {
|
||||
if (currentAdapter === 'lark') {
|
||||
const enableWebhook = currentAdapterConfig?.['enable-webhook'];
|
||||
if (enableWebhook === false) {
|
||||
// Hide encrypt-key field when webhook is disabled
|
||||
setFilteredDynamicFormConfigList(
|
||||
dynamicFormConfigList.filter(
|
||||
(config) => config.name !== 'encrypt-key',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Show all fields when webhook is enabled or undefined
|
||||
setFilteredDynamicFormConfigList(dynamicFormConfigList);
|
||||
}
|
||||
} else {
|
||||
// For non-Lark adapters, show all fields
|
||||
setFilteredDynamicFormConfigList(dynamicFormConfigList);
|
||||
}
|
||||
}, [currentAdapter, currentAdapterConfig, dynamicFormConfigList]);
|
||||
|
||||
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
|
||||
const copyToClipboard = () => {
|
||||
console.log('[Copy] Attempting to copy from input element');
|
||||
@@ -283,6 +310,7 @@ export default function BotForm({
|
||||
name: item.name,
|
||||
required: item.required,
|
||||
type: parseDynamicFormItemType(item.type),
|
||||
options: item.options,
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -498,34 +526,36 @@ export default function BotForm({
|
||||
</div>
|
||||
|
||||
{/* Webhook 地址显示(统一 Webhook 模式) */}
|
||||
{webhookUrl && (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
ref={webhookInputRef}
|
||||
value={webhookUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
||||
onClick={(e) => {
|
||||
// 点击输入框时自动全选
|
||||
(e.target as HTMLInputElement).select();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t('bots.webhookUrlHint')}
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
{webhookUrl &&
|
||||
(currentAdapter !== 'lark' ||
|
||||
currentAdapterConfig?.['enable-webhook'] !== false) && (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
ref={webhookInputRef}
|
||||
value={webhookUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
||||
onClick={(e) => {
|
||||
// 点击输入框时自动全选
|
||||
(e.target as HTMLInputElement).select();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t('bots.webhookUrlHint')}
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -622,13 +652,13 @@ export default function BotForm({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
||||
{showDynamicForm && filteredDynamicFormConfigList.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-lg font-medium">
|
||||
{t('bots.adapterConfig')}
|
||||
</div>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={dynamicFormConfigList}
|
||||
itemConfigList={filteredDynamicFormConfigList}
|
||||
initialValues={form.watch('adapter_config')}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values);
|
||||
|
||||
@@ -44,12 +44,35 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
|
||||
const strArr = str.split('');
|
||||
return strArr;
|
||||
}
|
||||
|
||||
// 根据日志级别返回对应的样式类
|
||||
function getLevelStyles(level: string) {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||||
case 'warning':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
|
||||
case 'info':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'debug':
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`${styles.botLogCardContainer}`}>
|
||||
{/* 头部标签,时间 */}
|
||||
<div className={`${styles.cardTitleContainer}`}>
|
||||
<div className={`flex flex-row gap-4`}>
|
||||
<div className={`${styles.tag}`}>{botLog.level}</div>
|
||||
<div className={`flex flex-row gap-2 items-center`}>
|
||||
<div
|
||||
className={`px-2 py-1 rounded text-xs font-medium uppercase ${getLevelStyles(
|
||||
botLog.level,
|
||||
)}`}
|
||||
>
|
||||
{botLog.level}
|
||||
</div>
|
||||
{botLog.message_session_id && (
|
||||
<div
|
||||
className={`${styles.tag} ${styles.chatTag}`}
|
||||
@@ -60,6 +83,7 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
|
||||
toast.success(t('common.copySuccess'));
|
||||
});
|
||||
}}
|
||||
title={t('common.clickToCopy')}
|
||||
>
|
||||
<svg
|
||||
className="icon"
|
||||
@@ -67,8 +91,8 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="1664"
|
||||
width="20"
|
||||
height="20"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
@@ -87,7 +111,6 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
{/* 会话ID */}
|
||||
|
||||
<span className={`${styles.chatId}`}>
|
||||
{getSubChatId(botLog.message_session_id)}
|
||||
@@ -95,22 +118,25 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div>{formatTime(botLog.timestamp)}</div>
|
||||
</div>
|
||||
<div className={`${styles.cardTitleContainer} ${styles.cardText}`}>
|
||||
{botLog.text}
|
||||
</div>
|
||||
<PhotoProvider className={``}>
|
||||
<div className={`w-50 mt-2`}>
|
||||
{botLog.images.map((item) => (
|
||||
<img
|
||||
key={item}
|
||||
src={`${baseURL}/api/v1/files/image/${item}`}
|
||||
alt=""
|
||||
/>
|
||||
))}
|
||||
<div className={`${styles.timestamp}`}>
|
||||
{formatTime(botLog.timestamp)}
|
||||
</div>
|
||||
</PhotoProvider>
|
||||
</div>
|
||||
<div className={`${styles.cardText}`}>{botLog.text}</div>
|
||||
{botLog.images.length > 0 && (
|
||||
<PhotoProvider>
|
||||
<div className={`flex flex-wrap gap-2 mt-3`}>
|
||||
{botLog.images.map((item) => (
|
||||
<img
|
||||
key={item}
|
||||
src={`${baseURL}/api/v1/files/image/${item}`}
|
||||
alt=""
|
||||
className="max-w-xs rounded cursor-pointer hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PhotoProvider>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
import { BotLogCard } from '@/app/home/bots/components/bot-log/view/BotLogCard';
|
||||
import styles from './botLog.module.css';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import { debounce } from 'lodash';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -14,9 +22,21 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
const manager = useRef(new BotLogManager(botId)).current;
|
||||
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
|
||||
const [autoFlush, setAutoFlush] = useState(true);
|
||||
const [selectedLevels, setSelectedLevels] = useState<string[]>([
|
||||
'info',
|
||||
'warning',
|
||||
'error',
|
||||
]);
|
||||
const listContainerRef = useRef<HTMLDivElement>(null);
|
||||
const botLogListRef = useRef<BotLog[]>(botLogList);
|
||||
|
||||
const logLevels = [
|
||||
{ value: 'error', label: 'ERROR' },
|
||||
{ value: 'warning', label: 'WARNING' },
|
||||
{ value: 'info', label: 'INFO' },
|
||||
{ value: 'debug', label: 'DEBUG' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
initComponent();
|
||||
return () => {
|
||||
@@ -28,6 +48,42 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
botLogListRef.current = botLogList;
|
||||
}, [botLogList]);
|
||||
|
||||
// 根据级别过滤日志
|
||||
const filteredLogs = useMemo(() => {
|
||||
if (selectedLevels.length === 0) {
|
||||
return botLogList;
|
||||
}
|
||||
return botLogList.filter((log) => selectedLevels.includes(log.level));
|
||||
}, [botLogList, selectedLevels]);
|
||||
|
||||
const handleLevelToggle = (levelValue: string) => {
|
||||
setSelectedLevels((prev) => {
|
||||
if (prev.includes(levelValue)) {
|
||||
return prev.filter((l) => l !== levelValue);
|
||||
} else {
|
||||
return [...prev, levelValue];
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const getDisplayText = () => {
|
||||
if (selectedLevels.length === 0) {
|
||||
return t('bots.selectLevel');
|
||||
}
|
||||
if (selectedLevels.length === logLevels.length) {
|
||||
return t('bots.allLevels');
|
||||
}
|
||||
// 如果选中3个或以上,显示数量
|
||||
if (selectedLevels.length >= 3) {
|
||||
return `${selectedLevels.length} ${t('bots.levelsSelected')}`;
|
||||
}
|
||||
// 显示选中级别的标签(大写形式)
|
||||
return logLevels
|
||||
.filter((level) => selectedLevels.includes(level.value))
|
||||
.map((level) => level.label)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
// 观测自动刷新状态
|
||||
useEffect(() => {
|
||||
if (autoFlush) {
|
||||
@@ -116,9 +172,43 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
<div className={`${styles.listHeader}`}>
|
||||
<div className={'mr-2'}>{t('bots.enableAutoRefresh')}</div>
|
||||
<Switch checked={autoFlush} onCheckedChange={(e) => setAutoFlush(e)} />
|
||||
<div className={'ml-4 mr-2'}>{t('bots.logLevel')}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-[180px] flex items-center justify-between"
|
||||
>
|
||||
<span className="text-sm truncate flex-1 text-left">
|
||||
{getDisplayText()}
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 flex-shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{logLevels.map((level) => (
|
||||
<div key={level.value} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={level.value}
|
||||
checked={selectedLevels.includes(level.value)}
|
||||
onCheckedChange={() => handleLevelToggle(level.value)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={level.value}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{level.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{botLogList.map((botLog) => {
|
||||
{filteredLogs.map((botLog) => {
|
||||
return <BotLogCard botLog={botLog} key={botLog.seq_id} />;
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
.botLogListContainer {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow-y: scroll;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.botLogCardContainer {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #cbd5e1;
|
||||
padding: 1.2rem;
|
||||
margin-bottom: 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.botLogCardContainer:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .botLogCardContainer {
|
||||
@@ -23,40 +34,74 @@
|
||||
border: 1px solid #2a2a2e;
|
||||
}
|
||||
|
||||
:global(.dark) .botLogCardContainer:hover {
|
||||
border-color: #3a3a3e;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: flex;
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.2rem;
|
||||
height: 1.5rem;
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.4rem;
|
||||
background-color: #a5d8ff;
|
||||
color: #ffffff;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
height: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
max-width: 16rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
:global(.dark) .tag {
|
||||
background-color: #1e3a8a;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.chatTag {
|
||||
color: #626262;
|
||||
background-color: #d1d1d1;
|
||||
color: #4b5563;
|
||||
background-color: #f3f4f6;
|
||||
text-transform: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.chatTag:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
:global(.dark) .chatTag {
|
||||
color: #9ca3af;
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .chatTag:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.chatId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
|
||||
'Courier New', monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.cardTitleContainer {
|
||||
@@ -65,9 +110,33 @@
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.cardText {
|
||||
margin-top: 0.4rem;
|
||||
color: #1e293b;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
max-width: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', sans-serif;
|
||||
}
|
||||
|
||||
:global(.dark) .cardText {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.dark) .timestamp {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,16 @@ import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Moon, Sun, Monitor } from 'lucide-react';
|
||||
import {
|
||||
Moon,
|
||||
Sun,
|
||||
Monitor,
|
||||
CircleHelp,
|
||||
Lightbulb,
|
||||
Lock,
|
||||
LogOut,
|
||||
KeyRound,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from 'next-themes';
|
||||
|
||||
import {
|
||||
@@ -184,23 +193,6 @@ export default function HomeSidebar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SidebarChild
|
||||
onClick={() => {
|
||||
setApiKeyDialogOpen(true);
|
||||
}}
|
||||
isSelected={false}
|
||||
icon={
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M10.7577 11.8281L18.6066 3.97919L20.0208 5.3934L18.6066 6.80761L21.0815 9.28249L19.6673 10.6967L17.1924 8.22183L15.7782 9.63604L17.8995 11.7574L16.4853 13.1716L14.364 11.0503L12.1719 13.2423C13.4581 15.1837 13.246 17.8251 11.5355 19.5355C9.58291 21.4882 6.41709 21.4882 4.46447 19.5355C2.51184 17.5829 2.51184 14.4171 4.46447 12.4645C6.17493 10.754 8.81633 10.5419 10.7577 11.8281ZM10.1213 18.1213C11.2929 16.9497 11.2929 15.0503 10.1213 13.8787C8.94975 12.7071 7.05025 12.7071 5.87868 13.8787C4.70711 15.0503 4.70711 16.9497 5.87868 18.1213C7.05025 19.2929 8.94975 19.2929 10.1213 18.1213Z"></path>
|
||||
</svg>
|
||||
}
|
||||
name={t('common.apiIntegration')}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
open={popoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
@@ -262,6 +254,23 @@ export default function HomeSidebar({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="text-sm font-medium">
|
||||
{t('common.integration')}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start font-normal"
|
||||
onClick={() => {
|
||||
setApiKeyDialogOpen(true);
|
||||
setPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<KeyRound className="w-4 h-4 mr-2" />
|
||||
{t('common.apiIntegration')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<span className="text-sm font-medium">{t('common.account')}</span>
|
||||
<Button
|
||||
@@ -289,34 +298,36 @@ export default function HomeSidebar({
|
||||
setPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-4 h-4 mr-2"
|
||||
>
|
||||
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path>
|
||||
</svg>
|
||||
<CircleHelp className="w-4 h-4 mr-2" />
|
||||
{t('common.helpDocs')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start font-normal"
|
||||
onClick={() => {
|
||||
setPasswordChangeOpen(true);
|
||||
window.open(
|
||||
'https://github.com/langbot-app/LangBot/issues',
|
||||
'_blank',
|
||||
);
|
||||
setPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-4 h-4 mr-2"
|
||||
>
|
||||
<path d="M6 8V7C6 3.68629 8.68629 1 12 1C15.3137 1 18 3.68629 18 7V8H20C20.5523 8 21 8.44772 21 9V21C21 21.5523 20.5523 22 20 22H4C3.44772 22 3 21.5523 3 21V9C3 8.44772 3.44772 8 4 8H6ZM19 10H5V20H19V10ZM11 15.7324C10.4022 15.3866 10 14.7403 10 14C10 12.8954 10.8954 12 12 12C13.1046 12 14 12.8954 14 14C14 14.7403 13.5978 15.3866 13 15.7324V18H11V15.7324ZM8 8H16V7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7V8Z"></path>
|
||||
</svg>
|
||||
{t('common.changePassword')}
|
||||
<Lightbulb className="w-4 h-4 mr-2" />
|
||||
{t('common.featureRequest')}
|
||||
</Button>
|
||||
{systemInfo?.allow_change_password && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start font-normal"
|
||||
onClick={() => {
|
||||
setPasswordChangeOpen(true);
|
||||
setPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<Lock className="w-4 h-4 mr-2" />
|
||||
{t('common.changePassword')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start font-normal"
|
||||
@@ -324,14 +335,7 @@ export default function HomeSidebar({
|
||||
handleLogout();
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-4 h-4 mr-2"
|
||||
>
|
||||
<path d="M4 18H6V20H18V4H6V6H4V3C4 2.44772 4.44772 2 5 2H19C19.5523 2 20 2.44772 20 3V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V18ZM6 11H13V13H6V16L1 12L6 8V11Z"></path>
|
||||
</svg>
|
||||
<LogOut className="w-4 h-4 mr-2" />
|
||||
{t('common.logout')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -2,4 +2,5 @@ export interface IChooseRequesterEntity {
|
||||
label: string;
|
||||
value: string;
|
||||
provider_category?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
@@ -33,19 +33,21 @@ export default function EmbeddingCard({ cardVO }: { cardVO: EmbeddingCardVO }) {
|
||||
</span>
|
||||
</div>
|
||||
{/* baseURL */}
|
||||
<div className={`${styles.baseURLContainer}`}>
|
||||
<svg
|
||||
className={`${styles.baseURLIcon}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="36"
|
||||
height="36"
|
||||
fill="rgba(98,98,98,1)"
|
||||
>
|
||||
<path d="M13.0607 8.11097L14.4749 9.52518C17.2086 12.2589 17.2086 16.691 14.4749 19.4247L14.1214 19.7782C11.3877 22.5119 6.95555 22.5119 4.22188 19.7782C1.48821 17.0446 1.48821 12.6124 4.22188 9.87874L5.6361 11.293C3.68348 13.2456 3.68348 16.4114 5.6361 18.364C7.58872 20.3166 10.7545 20.3166 12.7072 18.364L13.0607 18.0105C15.0133 16.0578 15.0133 12.892 13.0607 10.9394L11.6465 9.52518L13.0607 8.11097ZM19.7782 14.1214L18.364 12.7072C20.3166 10.7545 20.3166 7.58872 18.364 5.6361C16.4114 3.68348 13.2456 3.68348 11.293 5.6361L10.9394 5.98965C8.98678 7.94227 8.98678 11.1081 10.9394 13.0607L12.3536 14.4749L10.9394 15.8891L9.52518 14.4749C6.79151 11.7413 6.79151 7.30911 9.52518 4.57544L9.87874 4.22188C12.6124 1.48821 17.0446 1.48821 19.7782 4.22188C22.5119 6.95555 22.5119 11.3877 19.7782 14.1214Z"></path>
|
||||
</svg>
|
||||
<span className={`${styles.baseURLText}`}>{cardVO.baseURL}</span>
|
||||
</div>
|
||||
{cardVO.baseURL && (
|
||||
<div className={`${styles.baseURLContainer}`}>
|
||||
<svg
|
||||
className={`${styles.baseURLIcon}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
width="36"
|
||||
height="36"
|
||||
fill="rgba(98,98,98,1)"
|
||||
>
|
||||
<path d="M13.0607 8.11097L14.4749 9.52518C17.2086 12.2589 17.2086 16.691 14.4749 19.4247L14.1214 19.7782C11.3877 22.5119 6.95555 22.5119 4.22188 19.7782C1.48821 17.0446 1.48821 12.6124 4.22188 9.87874L5.6361 11.293C3.68348 13.2456 3.68348 16.4114 5.6361 18.364C7.58872 20.3166 10.7545 20.3166 12.7072 18.364L13.0607 18.0105C15.0133 16.0578 15.0133 12.892 13.0607 10.9394L11.6465 9.52518L13.0607 8.11097ZM19.7782 14.1214L18.364 12.7072C20.3166 10.7545 20.3166 7.58872 18.364 5.6361C16.4114 3.68348 13.2456 3.68348 11.293 5.6361L10.9394 5.98965C8.98678 7.94227 8.98678 11.1081 10.9394 13.0607L12.3536 14.4749L10.9394 15.8891L9.52518 14.4749C6.79151 11.7413 6.79151 7.30911 9.52518 4.57544L9.87874 4.22188C12.6124 1.48821 17.0446 1.48821 19.7782 4.22188C22.5119 6.95555 22.5119 11.3877 19.7782 14.1214Z"></path>
|
||||
</svg>
|
||||
<span className={`${styles.baseURLText}`}>{cardVO.baseURL}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,7 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
model_provider: z
|
||||
.string()
|
||||
.min(1, { message: t('models.modelProviderRequired') }),
|
||||
url: z.string().min(1, { message: t('models.requestURLRequired') }),
|
||||
url: z.string().optional(),
|
||||
api_key: z.string().optional(),
|
||||
extra_args: z.array(getExtraArgSchema(t)).optional(),
|
||||
});
|
||||
@@ -188,6 +188,7 @@ export default function EmbeddingForm({
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
provider_category: item.spec.provider_category || 'manufacturer',
|
||||
description: extractI18nObject(item.description) || undefined,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -243,7 +244,7 @@ export default function EmbeddingForm({
|
||||
description: '',
|
||||
requester: value.model_provider,
|
||||
requester_config: {
|
||||
base_url: value.url,
|
||||
base_url: value.url || '',
|
||||
timeout: 120,
|
||||
},
|
||||
extra_args: extraArgsObj,
|
||||
@@ -320,7 +321,7 @@ export default function EmbeddingForm({
|
||||
description: '',
|
||||
requester: form.getValues('model_provider'),
|
||||
requester_config: {
|
||||
base_url: form.getValues('url'),
|
||||
base_url: form.getValues('url') ?? '',
|
||||
timeout: 120,
|
||||
},
|
||||
api_keys: apiKey ? [apiKey] : [],
|
||||
@@ -425,6 +426,18 @@ export default function EmbeddingForm({
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t('models.builtin')}</SelectLabel>
|
||||
{requesterNameList
|
||||
.filter(
|
||||
(item) => item.provider_category === 'builtin',
|
||||
)
|
||||
.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<SelectGroup>
|
||||
<SelectLabel>
|
||||
{t('models.modelManufacturer')}
|
||||
@@ -468,29 +481,42 @@ export default function EmbeddingForm({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{currentModelProvider &&
|
||||
requesterNameList.find(
|
||||
(item) => item.value === currentModelProvider,
|
||||
)?.description && (
|
||||
<FormDescription>
|
||||
{
|
||||
requesterNameList.find(
|
||||
(item) => item.value === currentModelProvider,
|
||||
)?.description
|
||||
}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('models.requestURL')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{!['seekdb-embedding'].includes(currentModelProvider) && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('models.requestURL')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!['ollama-chat'].includes(currentModelProvider) && (
|
||||
{!['ollama-chat', 'seekdb-embedding'].includes(
|
||||
currentModelProvider,
|
||||
) && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DialogContent } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Message,
|
||||
@@ -60,6 +61,7 @@ export default function DebugDialog({
|
||||
const [rawModeMessages, setRawModeMessages] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [streamOutput, setStreamOutput] = useState(true);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
@@ -384,7 +386,7 @@ export default function DebugDialog({
|
||||
|
||||
// 通过WebSocket发送消息
|
||||
// 不在本地添加消息,等待后端广播回来(带有正确的ID)
|
||||
wsClientRef.current.sendMessage(messageChain);
|
||||
wsClientRef.current.sendMessage(messageChain, streamOutput);
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
toast.error(t('pipelines.debugDialog.sendFailed'));
|
||||
@@ -897,7 +899,18 @@ export default function DebugDialog({
|
||||
)}
|
||||
|
||||
<div className="p-4 pb-0 bg-white dark:bg-black flex gap-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('pipelines.debugDialog.streamOutput')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={streamOutput}
|
||||
onCheckedChange={setStreamOutput}
|
||||
disabled={!isConnected}
|
||||
className="data-[state=checked]:bg-[#2288ee]"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
|
||||
@@ -52,6 +52,7 @@ export default function PipelineFormComponent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
||||
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
|
||||
|
||||
const formSchema = isEditMode
|
||||
@@ -345,25 +346,17 @@ export default function PipelineFormComponent({
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
setShowCopyConfirm(true);
|
||||
};
|
||||
|
||||
const confirmCopy = () => {
|
||||
if (pipelineId) {
|
||||
let newPipelineName = '';
|
||||
httpClient
|
||||
.getPipeline(pipelineId)
|
||||
.then((resp) => {
|
||||
const originalPipeline = resp.pipeline;
|
||||
newPipelineName = `${originalPipeline.name}${t(
|
||||
'pipelines.copySuffix',
|
||||
)}`;
|
||||
const newPipeline: Pipeline = {
|
||||
name: newPipelineName,
|
||||
description: originalPipeline.description,
|
||||
config: originalPipeline.config,
|
||||
};
|
||||
return httpClient.createPipeline(newPipeline);
|
||||
})
|
||||
.copyPipeline(pipelineId)
|
||||
.then(() => {
|
||||
onFinish();
|
||||
toast.success(`${t('common.copySuccess')}: ${newPipelineName}`);
|
||||
toast.success(t('common.copySuccess'));
|
||||
setShowCopyConfirm(false);
|
||||
onCancel();
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -547,6 +540,22 @@ export default function PipelineFormComponent({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 复制确认对话框 */}
|
||||
<Dialog open={showCopyConfirm} onOpenChange={setShowCopyConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('pipelines.copyConfirmTitle')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{t('pipelines.copyConfirmation')}</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowCopyConfirm(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={confirmCopy}>{t('common.confirm')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface IPluginCardVO {
|
||||
status: string;
|
||||
components: PluginComponent[];
|
||||
debug: boolean;
|
||||
hasUpdate?: boolean;
|
||||
}
|
||||
|
||||
export class PluginCardVO implements IPluginCardVO {
|
||||
@@ -28,6 +29,7 @@ export class PluginCardVO implements IPluginCardVO {
|
||||
install_info: Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
status: string;
|
||||
components: PluginComponent[];
|
||||
hasUpdate?: boolean;
|
||||
|
||||
constructor(prop: IPluginCardVO) {
|
||||
this.author = prop.author;
|
||||
@@ -42,5 +44,6 @@ export class PluginCardVO implements IPluginCardVO {
|
||||
this.debug = prop.debug;
|
||||
this.install_source = prop.install_source;
|
||||
this.install_info = prop.install_info;
|
||||
this.hasUpdate = prop.hasUpdate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-fo
|
||||
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
|
||||
import styles from '@/app/home/plugins/plugins.module.css';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { isNewerVersion } from '@/app/utils/versionCompare';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -72,10 +74,68 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
getPluginList();
|
||||
}
|
||||
|
||||
function getPluginList() {
|
||||
httpClient.getPlugins().then((value) => {
|
||||
async function getPluginList() {
|
||||
try {
|
||||
// 获取已安装插件列表
|
||||
const installedPluginsResp = await httpClient.getPlugins();
|
||||
const installedPlugins = installedPluginsResp.plugins;
|
||||
|
||||
// 获取市场插件列表
|
||||
const client = getCloudServiceClientSync();
|
||||
const marketplaceResp = await client.getMarketplacePlugins(1, 100);
|
||||
const marketplacePlugins = marketplaceResp.plugins;
|
||||
|
||||
// 创建市场插件映射,便于快速查找
|
||||
const marketplacePluginMap = new Map();
|
||||
marketplacePlugins.forEach((plugin) => {
|
||||
const key = `${plugin.author}/${plugin.name}`;
|
||||
marketplacePluginMap.set(key, plugin);
|
||||
});
|
||||
|
||||
// 转换并比较版本号
|
||||
const pluginCards = installedPlugins.map((plugin) => {
|
||||
const cardVO = new PluginCardVO({
|
||||
author: plugin.manifest.manifest.metadata.author ?? '',
|
||||
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
|
||||
description: extractI18nObject(
|
||||
plugin.manifest.manifest.metadata.description ?? {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
),
|
||||
debug: plugin.debug,
|
||||
enabled: plugin.enabled,
|
||||
name: plugin.manifest.manifest.metadata.name,
|
||||
version: plugin.manifest.manifest.metadata.version ?? '',
|
||||
status: plugin.status,
|
||||
components: plugin.components,
|
||||
priority: plugin.priority,
|
||||
install_source: plugin.install_source,
|
||||
install_info: plugin.install_info,
|
||||
});
|
||||
|
||||
// 检查是否来自市场且有更新
|
||||
if (cardVO.install_source === 'marketplace') {
|
||||
const marketplaceKey = `${cardVO.author}/${cardVO.name}`;
|
||||
const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
|
||||
if (marketplacePlugin && marketplacePlugin.latest_version) {
|
||||
cardVO.hasUpdate = isNewerVersion(
|
||||
marketplacePlugin.latest_version,
|
||||
cardVO.version,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return cardVO;
|
||||
});
|
||||
|
||||
setPluginList(pluginCards);
|
||||
} catch (error) {
|
||||
console.error('获取插件列表失败:', error);
|
||||
// 失败时仍显示已安装插件,不影响用户体验
|
||||
const installedPluginsResp = await httpClient.getPlugins();
|
||||
setPluginList(
|
||||
value.plugins.map((plugin) => {
|
||||
installedPluginsResp.plugins.map((plugin) => {
|
||||
return new PluginCardVO({
|
||||
author: plugin.manifest.manifest.metadata.author ?? '',
|
||||
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
|
||||
@@ -97,7 +157,7 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
|
||||
});
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user