mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-04 12:56:02 +00:00
Compare commits
113 Commits
feat/long-
...
v4.9.5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8aedd02b3 | ||
|
|
ea638cab80 | ||
|
|
7129dd536e | ||
|
|
1b1cc7769b | ||
|
|
44b8354dfd | ||
|
|
55ec9d11ae | ||
|
|
5b3d3801b5 | ||
|
|
9f1ea75d09 | ||
|
|
6e37aae636 | ||
|
|
921d12f596 | ||
|
|
6bf6deaefd | ||
|
|
1201949f2c | ||
|
|
1c419e3591 | ||
|
|
b0a9be77b0 | ||
|
|
e02ade5a30 | ||
|
|
1a51ba8e7e | ||
|
|
e7b22d6ebf | ||
|
|
dddfa8ac79 | ||
|
|
99e2976826 | ||
|
|
71e44f0e54 | ||
|
|
4c904c2375 | ||
|
|
498d030da9 | ||
|
|
c111bf1714 | ||
|
|
6570f276d2 | ||
|
|
42e1e038bd | ||
|
|
d0e54a45c7 | ||
|
|
23fa47b07e | ||
|
|
4902c1d3b2 | ||
|
|
a6f96e5209 | ||
|
|
37c41bcfe4 | ||
|
|
9e223949a7 | ||
|
|
267bd72c63 | ||
|
|
af0d00e5e9 | ||
|
|
244e16c491 | ||
|
|
cad259fe39 | ||
|
|
bc3199bf29 | ||
|
|
127dc455c3 | ||
|
|
e8dc6fde53 | ||
|
|
4a97895dea | ||
|
|
3c0495fc51 | ||
|
|
dfd25deb68 | ||
|
|
f4db53b759 | ||
|
|
9f90341dcb | ||
|
|
67b726afb2 | ||
|
|
01852b81d4 | ||
|
|
4d6f109788 | ||
|
|
e1e5e7aedf | ||
|
|
cd53abc440 | ||
|
|
16a15a122a | ||
|
|
6fa653f232 | ||
|
|
c13971d7d6 | ||
|
|
9c659ce8fa | ||
|
|
c9fc64360f | ||
|
|
88a04fdbe8 | ||
|
|
bbe019f0c6 | ||
|
|
865f6ee81b | ||
|
|
bd5ec59b7c | ||
|
|
9c0cc1003d | ||
|
|
ea07d8ad00 | ||
|
|
3ac3fad4bc | ||
|
|
254a13bba3 | ||
|
|
4355f0fa78 | ||
|
|
031737f05d | ||
|
|
9e366fc536 | ||
|
|
8bd6442965 | ||
|
|
1a1eadb282 | ||
|
|
eed72b1c12 | ||
|
|
351350ea03 | ||
|
|
bc3d6ba92f | ||
|
|
345e4baf2a | ||
|
|
6c64dc057f | ||
|
|
eec0a9c9d9 | ||
|
|
6896a55485 | ||
|
|
4b0fad233e | ||
|
|
52eb991a70 | ||
|
|
10c716be0c | ||
|
|
6e77351eda | ||
|
|
20f5ebd9b8 | ||
|
|
d2c75329cf | ||
|
|
7e2fe082f0 | ||
|
|
d451b059fd | ||
|
|
93c52fcd4c | ||
|
|
f1608682e6 | ||
|
|
077e631c13 | ||
|
|
d7df1f05d1 | ||
|
|
8b8cfb76de | ||
|
|
79311ccde3 | ||
|
|
def798bf1f | ||
|
|
5290834b8b | ||
|
|
89064a9d5b | ||
|
|
8c2aef3734 | ||
|
|
3fb9e542b6 | ||
|
|
01844d8687 | ||
|
|
2655425fbe | ||
|
|
bd15b630b0 | ||
|
|
fe5ce68436 | ||
|
|
0541b05966 | ||
|
|
13cb0aa9be | ||
|
|
a048369b38 | ||
|
|
9ae0c263dc | ||
|
|
a4e66f6459 | ||
|
|
2a74a8d6ae | ||
|
|
d31f25c8df | ||
|
|
11c05ea8db | ||
|
|
2b8bd1cc71 | ||
|
|
9148e02679 | ||
|
|
fd15284d91 | ||
|
|
8c7a0ec027 | ||
|
|
a1cef5c9bf | ||
|
|
90438cec36 | ||
|
|
95dd19f4d7 | ||
|
|
c64eb58cf8 | ||
|
|
fbd3d7ae3a |
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
name: 漏洞反馈
|
name: 漏洞反馈
|
||||||
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://docs.langbot.app/zh/workshop/network-details.html
|
description: 【供中文用户】报错或漏洞请使用这个模板创建,不使用此模板创建的异常、漏洞相关issue将被直接关闭。由于自己操作不当/不甚了解所用技术栈引起的网络连接问题恕无法解决,请勿提 issue。容器间网络连接问题,参考文档 https://link.langbot.app/zh/docs/network
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
@@ -1,5 +1,5 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://docs.langbot.app/en/workshop/network-details.html
|
description: Report bugs or vulnerabilities using this template. For container network connection issues, refer to the documentation https://link.langbot.app/en/docs/network
|
||||||
title: "[Bug]: "
|
title: "[Bug]: "
|
||||||
labels: ["bug?"]
|
labels: ["bug?"]
|
||||||
body:
|
body:
|
||||||
|
|||||||
@@ -9,16 +9,14 @@ repos:
|
|||||||
# Run the formatter of backend.
|
# Run the formatter of backend.
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
|
||||||
rev: v3.1.0
|
|
||||||
hooks:
|
|
||||||
- id: prettier
|
|
||||||
types_or: [javascript, jsx, ts, tsx, css, scss]
|
|
||||||
additional_dependencies:
|
|
||||||
- prettier@3.1.0
|
|
||||||
|
|
||||||
- repo: local
|
- repo: local
|
||||||
hooks:
|
hooks:
|
||||||
|
- id: prettier
|
||||||
|
name: prettier
|
||||||
|
entry: npx --prefix web prettier --write --ignore-unknown
|
||||||
|
language: system
|
||||||
|
types_or: [javascript, jsx, ts, tsx, css, scss]
|
||||||
|
|
||||||
- id: lint-staged
|
- id: lint-staged
|
||||||
name: lint-staged
|
name: lint-staged
|
||||||
entry: cd web && pnpm lint-staged
|
entry: cd web && pnpm lint-staged
|
||||||
|
|||||||
12
README.md
12
README.md
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Website</a> |
|
<a href="https://langbot.app">Website</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features">Features</a> |
|
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide">Docs</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Docs</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">Plugin Market</a> |
|
<a href="https://space.langbot.app">Plugin Market</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
|
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
|
||||||
@@ -45,7 +45,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
|||||||
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
|
- **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required.
|
||||||
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
- **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling.
|
||||||
|
|
||||||
[→ Learn more about all features](https://docs.langbot.app/en/insight/features)
|
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -76,7 +76,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**More options:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt) · [Kubernetes](./docker/README_K8S.md)
|
**More options:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,7 +124,7 @@ docker compose up -d
|
|||||||
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
||||||
|
|
||||||
[→ View all integrations](https://docs.langbot.app/en/insight/features)
|
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
16
README_CN.md
16
README_CN.md
@@ -21,9 +21,9 @@
|
|||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官网</a> |
|
<a href="https://langbot.app">官网</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a> |
|
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/guide.html">文档</a> |
|
<a href="https://link.langbot.app/zh/docs/guide">文档</a> |
|
||||||
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
<a href="https://space.langbot.app/cloud">Cloud</a> |
|
||||||
<a href="https://space.langbot.app">插件市场</a> |
|
<a href="https://space.langbot.app">插件市场</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||||
@@ -34,8 +34,6 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 什么是 LangBot?
|
|
||||||
|
|
||||||
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
|
LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时通信机器人。它将大语言模型(LLM)连接到各种聊天平台,帮助你创建能够对话、执行任务、并集成到现有工作流程中的智能 Agent。
|
||||||
|
|
||||||
### 核心能力
|
### 核心能力
|
||||||
@@ -43,11 +41,11 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
|||||||
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
||||||
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||||
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
||||||
- **插件生态** — 数百个插件,事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||||
|
|
||||||
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,7 +76,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**更多方式:** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手动部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [宝塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手动部署](https://link.langbot.app/zh/docs/manual-deploy) · [宝塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -127,7 +125,7 @@ docker compose up -d
|
|||||||
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
||||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
|
|
||||||
[→ 查看完整集成列表](https://docs.langbot.app/zh/insight/features.html)
|
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
### TTS(语音合成)
|
### TTS(语音合成)
|
||||||
|
|
||||||
|
|||||||
12
README_ES.md
12
README_ES.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Inicio</a> |
|
<a href="https://langbot.app">Inicio</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Características</a> |
|
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Documentación</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Documentación</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
<a href="https://space.langbot.app">Mercado de Plugins</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
|
<a href="https://langbot.featurebase.app/roadmap">Hoja de Ruta</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
|||||||
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
|
- **Panel de Gestión Web** — Configure, gestione y monitoree sus bots a través de una interfaz de navegador intuitiva. Sin necesidad de editar YAML.
|
||||||
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
- **Arquitectura Multi-Pipeline** — Diferentes bots para diferentes escenarios, con monitoreo completo y manejo de excepciones.
|
||||||
|
|
||||||
[→ Conocer más sobre todas las funcionalidades](https://docs.langbot.app/en/insight/features.html)
|
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Más opciones:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**Más opciones:** [Docker](https://link.langbot.app/en/docs/docker) · [Manual](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
||||||
|
|
||||||
[→ Ver todas las integraciones](https://docs.langbot.app/en/insight/features.html)
|
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README_FR.md
12
README_FR.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Accueil</a> |
|
<a href="https://langbot.app">Accueil</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Fonctionnalités</a> |
|
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Documentation</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Documentation</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
<a href="https://space.langbot.app">Marché des Plugins</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
|
<a href="https://langbot.featurebase.app/roadmap">Feuille de Route</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
|||||||
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
|
- **Panneau de Gestion Web** — Configurez, gérez et surveillez vos bots via une interface navigateur intuitive. Aucune édition de YAML requise.
|
||||||
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
|
- **Architecture Multi-Pipeline** — Différents bots pour différents scénarios, avec surveillance complète et gestion des exceptions.
|
||||||
|
|
||||||
[→ En savoir plus sur toutes les fonctionnalités](https://docs.langbot.app/en/insight/features.html)
|
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Plus d'options :** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manuel](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**Plus d'options :** [Docker](https://link.langbot.app/en/docs/docker) · [Manuel](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||||
|
|
||||||
[→ Voir toutes les intégrations](https://docs.langbot.app/en/insight/features.html)
|
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README_JP.md
12
README_JP.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">ホーム</a> |
|
<a href="https://langbot.app">ホーム</a> |
|
||||||
<a href="https://docs.langbot.app/ja/insight/features.html">機能</a> |
|
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
||||||
<a href="https://docs.langbot.app/ja/insight/guide.html">ドキュメント</a> |
|
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
||||||
<a href="https://docs.langbot.app/ja/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
|||||||
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
- **Web管理パネル** — 直感的なブラウザインターフェースからボットの設定、管理、監視が可能。YAML編集は不要。
|
||||||
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
- **マルチパイプラインアーキテクチャ** — 異なるシナリオに異なるボットを配置し、包括的な監視と例外処理を実現。
|
||||||
|
|
||||||
[→ すべての機能について詳しく見る](https://docs.langbot.app/ja/insight/features.html)
|
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**その他:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [手動デプロイ](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**その他:** [Docker](https://link.langbot.app/en/docs/docker) · [手動デプロイ](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
||||||
|
|
||||||
[→ すべての統合を表示](https://docs.langbot.app/en/insight/features.html)
|
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README_KO.md
12
README_KO.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">홈</a> |
|
<a href="https://langbot.app">홈</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">기능</a> |
|
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">문서</a> |
|
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
|||||||
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. YAML 편집 불필요.
|
||||||
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
- **멀티 파이프라인 아키텍처** — 다양한 시나리오에 맞는 다양한 봇 구성, 종합 모니터링 및 예외 처리.
|
||||||
|
|
||||||
[→ 모든 기능 자세히 보기](https://docs.langbot.app/en/insight/features.html)
|
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**더 많은 옵션:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [수동 배포](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**더 많은 옵션:** [Docker](https://link.langbot.app/en/docs/docker) · [수동 배포](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
||||||
|
|
||||||
[→ 모든 통합 보기](https://docs.langbot.app/en/insight/features.html)
|
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README_RU.md
12
README_RU.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Главная</a> |
|
<a href="https://langbot.app">Главная</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Возможности</a> |
|
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Документация</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot — это **платформа с открытым исходным к
|
|||||||
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование YAML не требуется.
|
||||||
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
- **Мультиконвейерная архитектура** — Разные боты для разных сценариев с комплексным мониторингом и обработкой исключений.
|
||||||
|
|
||||||
[→ Подробнее обо всех возможностях](https://docs.langbot.app/en/insight/features.html)
|
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Другие варианты:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Ручная установка](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**Другие варианты:** [Docker](https://link.langbot.app/en/docs/docker) · [Ручная установка](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||||
|
|
||||||
[→ Смотреть все интеграции](https://docs.langbot.app/en/insight/features.html)
|
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README_TW.md
12
README_TW.md
@@ -21,9 +21,9 @@
|
|||||||
[](https://gitcode.com/RockChinQ/LangBot)
|
[](https://gitcode.com/RockChinQ/LangBot)
|
||||||
|
|
||||||
<a href="https://langbot.app">官網</a> |
|
<a href="https://langbot.app">官網</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a> |
|
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||||
<a href="https://docs.langbot.app/zh/insight/guide.html">文件</a> |
|
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
||||||
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">外掛市場</a> |
|
<a href="https://space.langbot.app">外掛市場</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
|||||||
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
- **Web 管理面板** — 透過瀏覽器直觀地配置、管理和監控機器人,無需手動編輯設定檔。
|
||||||
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
- **多流水線架構** — 不同機器人用於不同場景,具備全面的監控和異常處理能力。
|
||||||
|
|
||||||
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**更多方式:** [Docker](https://docs.langbot.app/zh/deploy/langbot/docker.html) · [手動部署](https://docs.langbot.app/zh/deploy/langbot/manual.html) · [寶塔面板](https://docs.langbot.app/zh/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**更多方式:** [Docker](https://link.langbot.app/zh/docs/docker) · [手動部署](https://link.langbot.app/zh/docs/manual-deploy) · [寶塔面板](https://link.langbot.app/zh/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -139,7 +139,7 @@ docker compose up -d
|
|||||||
|-----------|------|
|
|-----------|------|
|
||||||
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
| 阿里雲百煉 | [外掛](https://github.com/Thetail001/LangBot_BailianTextToImagePlugin) |
|
||||||
|
|
||||||
[→ 查看完整整合列表](https://docs.langbot.app/zh/insight/features.html)
|
[→ 查看完整整合列表](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
12
README_VI.md
12
README_VI.md
@@ -19,9 +19,9 @@
|
|||||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||||
|
|
||||||
<a href="https://langbot.app">Trang chủ</a> |
|
<a href="https://langbot.app">Trang chủ</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/features.html">Tính năng</a> |
|
<a href="https://link.langbot.app/en/docs/features">Tính năng</a> |
|
||||||
<a href="https://docs.langbot.app/en/insight/guide.html">Tài liệu</a> |
|
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
||||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||||
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
<a href="https://space.langbot.app">Chợ Plugin</a> |
|
||||||
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
|
<a href="https://langbot.featurebase.app/roadmap">Lộ trình</a>
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
|||||||
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
|
- **Bảng quản lý Web** — Cấu hình, quản lý và giám sát bot thông qua giao diện trình duyệt trực quan. Không cần chỉnh sửa YAML.
|
||||||
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
|
- **Kiến trúc đa Pipeline** — Các bot khác nhau cho các kịch bản khác nhau, với giám sát toàn diện và xử lý ngoại lệ.
|
||||||
|
|
||||||
[→ Tìm hiểu thêm về tất cả tính năng](https://docs.langbot.app/en/insight/features.html)
|
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ docker compose up -d
|
|||||||
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
[](https://zeabur.com/en-US/templates/ZKTBDH)
|
||||||
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
[](https://railway.app/template/yRrAyL?referralCode=vogKPF)
|
||||||
|
|
||||||
**Thêm tùy chọn:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Thủ công](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md)
|
**Thêm tùy chọn:** [Docker](https://link.langbot.app/en/docs/docker) · [Thủ công](https://link.langbot.app/en/docs/manual-deploy) · [BTPanel](https://link.langbot.app/en/docs/bt-panel) · [Kubernetes](./docker/README_K8S.md)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ docker compose up -d
|
|||||||
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
||||||
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
|
||||||
|
|
||||||
[→ Xem tất cả tích hợp](https://docs.langbot.app/en/insight/features.html)
|
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ spec:
|
|||||||
### 参考资源
|
### 参考资源
|
||||||
|
|
||||||
- [LangBot 官方文档](https://docs.langbot.app)
|
- [LangBot 官方文档](https://docs.langbot.app)
|
||||||
- [Docker 部署文档](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
- [Docker 部署文档](https://link.langbot.app/zh/docs/docker)
|
||||||
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -625,5 +625,5 @@ spec:
|
|||||||
### References
|
### References
|
||||||
|
|
||||||
- [LangBot Official Documentation](https://docs.langbot.app)
|
- [LangBot Official Documentation](https://docs.langbot.app)
|
||||||
- [Docker Deployment Guide](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
- [Docker Deployment Guide](https://link.langbot.app/zh/docs/docker)
|
||||||
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
- [Kubernetes Official Documentation](https://kubernetes.io/docs/)
|
||||||
|
|||||||
@@ -34,4 +34,4 @@ services:
|
|||||||
|
|
||||||
networks:
|
networks:
|
||||||
langbot_network:
|
langbot_network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.8.7"
|
version = "4.9.5"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -61,10 +61,10 @@ dependencies = [
|
|||||||
"html2text>=2024.2.26",
|
"html2text>=2024.2.26",
|
||||||
"langchain>=0.2.0",
|
"langchain>=0.2.0",
|
||||||
"langchain-text-splitters>=0.0.1",
|
"langchain-text-splitters>=0.0.1",
|
||||||
"chromadb>=0.4.24",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.0.0b7",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.3.0rc1",
|
"langbot-plugin==0.3.6",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.8.7'
|
__version__ = '4.9.5'
|
||||||
|
|||||||
@@ -272,15 +272,30 @@ class DingTalkClient:
|
|||||||
|
|
||||||
message_data['Type'] = 'audio'
|
message_data['Type'] = 'audio'
|
||||||
elif incoming_message.message_type == 'file':
|
elif incoming_message.message_type == 'file':
|
||||||
down_list = incoming_message.get_down_list()
|
# 获取原始数据字典并提取嵌套的文件信息
|
||||||
if len(down_list) >= 2:
|
raw_data = incoming_message.to_dict()
|
||||||
message_data['File'] = await self.get_file_url(down_list[0])
|
file_info = raw_data.get('content', {})
|
||||||
message_data['Name'] = down_list[1]
|
|
||||||
|
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
|
||||||
|
if isinstance(file_info, str):
|
||||||
|
try:
|
||||||
|
file_info = json.loads(file_info)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
file_info = {}
|
||||||
|
|
||||||
|
download_code = file_info.get('downloadCode')
|
||||||
|
file_name = file_info.get('fileName')
|
||||||
|
|
||||||
|
if download_code and file_name:
|
||||||
|
# 转换 downloadCode 为可下载的真实 URL
|
||||||
|
message_data['File'] = await self.get_file_url(download_code)
|
||||||
|
message_data['Name'] = file_name
|
||||||
else:
|
else:
|
||||||
if self.logger:
|
if self.logger:
|
||||||
await self.logger.error(f'get_down_list() returned fewer than 2 elements: {down_list}')
|
await self.logger.error(f'Failed to extract file info from message content: {file_info}')
|
||||||
message_data['File'] = None
|
message_data['File'] = None
|
||||||
message_data['Name'] = None
|
message_data['Name'] = None
|
||||||
|
|
||||||
message_data['Type'] = 'file'
|
message_data['Type'] = 'file'
|
||||||
|
|
||||||
copy_message_data = message_data.copy()
|
copy_message_data = message_data.copy()
|
||||||
|
|||||||
3
src/langbot/libs/openclaw_weixin_api/__init__.py
Normal file
3
src/langbot/libs/openclaw_weixin_api/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from .client import OpenClawWeixinClient as OpenClawWeixinClient
|
||||||
|
from .types import ApiError as ApiError
|
||||||
|
from .types import LoginResult as LoginResult
|
||||||
807
src/langbot/libs/openclaw_weixin_api/client.py
Normal file
807
src/langbot/libs/openclaw_weixin_api/client.py
Normal file
@@ -0,0 +1,807 @@
|
|||||||
|
"""Async HTTP client for the OpenClaw WeChat API.
|
||||||
|
|
||||||
|
Implements the iLink Bot API protocol.
|
||||||
|
Reference: https://github.com/epiral/weixin-bot
|
||||||
|
|
||||||
|
Endpoints: getUpdates (long-poll), sendMessage, getUploadUrl, getConfig, sendTyping.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import struct
|
||||||
|
import typing
|
||||||
|
import uuid
|
||||||
|
from typing import Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
ApiError,
|
||||||
|
CDNMedia,
|
||||||
|
FileItem,
|
||||||
|
GetConfigResponse,
|
||||||
|
GetUpdatesResponse,
|
||||||
|
GetUploadUrlResponse,
|
||||||
|
ImageItem,
|
||||||
|
LoginResult,
|
||||||
|
MessageItem,
|
||||||
|
QRCodeResponse,
|
||||||
|
QRStatusResponse,
|
||||||
|
RefMessage,
|
||||||
|
TextItem,
|
||||||
|
VideoItem,
|
||||||
|
VoiceItem,
|
||||||
|
WeixinMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger('openclaw-weixin-sdk')
|
||||||
|
|
||||||
|
DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
|
||||||
|
CDN_BASE_URL = 'https://novac2c.cdn.weixin.qq.com/c2c'
|
||||||
|
|
||||||
|
CHANNEL_VERSION = '1.0.0'
|
||||||
|
|
||||||
|
DEFAULT_API_TIMEOUT = 15
|
||||||
|
DEFAULT_LONG_POLL_TIMEOUT = 40
|
||||||
|
DEFAULT_CONFIG_TIMEOUT = 10
|
||||||
|
DEFAULT_QR_POLL_TIMEOUT = 35
|
||||||
|
|
||||||
|
SESSION_EXPIRED_ERRCODE = -14
|
||||||
|
|
||||||
|
DEFAULT_BOT_TYPE = '3'
|
||||||
|
|
||||||
|
# Maximum text length per message chunk (WeChat limit)
|
||||||
|
MAX_TEXT_CHUNK_SIZE = 2000
|
||||||
|
|
||||||
|
|
||||||
|
def _random_wechat_uin() -> str:
|
||||||
|
"""Generate the X-WECHAT-UIN header: random uint32 -> decimal string -> base64."""
|
||||||
|
rand_bytes = os.urandom(4)
|
||||||
|
uint32_val = struct.unpack('>I', rand_bytes)[0]
|
||||||
|
return base64.b64encode(str(uint32_val).encode('utf-8')).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
def _build_base_info() -> dict:
|
||||||
|
"""Build the base_info payload included in every API request."""
|
||||||
|
return {'channel_version': CHANNEL_VERSION}
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_text(text: str, max_size: int = MAX_TEXT_CHUNK_SIZE) -> list[str]:
|
||||||
|
"""Split long text into chunks that fit within WeChat's message size limit."""
|
||||||
|
if len(text) <= max_size:
|
||||||
|
return [text]
|
||||||
|
chunks = []
|
||||||
|
while text:
|
||||||
|
chunks.append(text[:max_size])
|
||||||
|
text = text[max_size:]
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawWeixinClient:
|
||||||
|
"""Async client for the OpenClaw WeChat HTTP JSON API."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str, token: str):
|
||||||
|
self.base_url = base_url.rstrip('/')
|
||||||
|
self.token = token
|
||||||
|
self._session: Optional[aiohttp.ClientSession] = None
|
||||||
|
|
||||||
|
async def _get_session(self) -> aiohttp.ClientSession:
|
||||||
|
if self._session is None or self._session.closed:
|
||||||
|
self._session = aiohttp.ClientSession()
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
|
||||||
|
def _build_headers(self) -> dict[str, str]:
|
||||||
|
headers = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'AuthorizationType': 'ilink_bot_token',
|
||||||
|
'X-WECHAT-UIN': _random_wechat_uin(),
|
||||||
|
}
|
||||||
|
if self.token:
|
||||||
|
headers['Authorization'] = f'Bearer {self.token}'
|
||||||
|
return headers
|
||||||
|
|
||||||
|
async def _post(self, endpoint: str, payload: dict, timeout: float = DEFAULT_API_TIMEOUT) -> dict:
|
||||||
|
"""Make a POST request and return the JSON response.
|
||||||
|
|
||||||
|
Raises ApiError on HTTP errors or when the response contains a non-zero errcode.
|
||||||
|
"""
|
||||||
|
payload['base_info'] = _build_base_info()
|
||||||
|
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f'{self.base_url}/{endpoint}'
|
||||||
|
headers = self._build_headers()
|
||||||
|
|
||||||
|
async with session.post(
|
||||||
|
url, json=payload, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ApiError(
|
||||||
|
f'OpenClaw API error {resp.status}: {text}',
|
||||||
|
status=resp.status,
|
||||||
|
)
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
|
||||||
|
# Check for application-level errors in the response body
|
||||||
|
errcode = data.get('errcode') or data.get('ret')
|
||||||
|
if errcode and errcode != 0:
|
||||||
|
raise ApiError(
|
||||||
|
data.get('errmsg') or f'API errcode {errcode}',
|
||||||
|
status=200,
|
||||||
|
code=errcode,
|
||||||
|
payload=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
async def get_updates(
|
||||||
|
self, get_updates_buf: str = '', timeout: float = DEFAULT_LONG_POLL_TIMEOUT
|
||||||
|
) -> GetUpdatesResponse:
|
||||||
|
"""Long-poll for new messages.
|
||||||
|
|
||||||
|
Note: This method does NOT raise ApiError for errcode responses —
|
||||||
|
it returns them in the GetUpdatesResponse so the caller can handle
|
||||||
|
session expiry and other errors with full context.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Bypass the errcode check in _post since get_updates needs
|
||||||
|
# to return error info (e.g. session expired) to the caller.
|
||||||
|
payload: dict = {'get_updates_buf': get_updates_buf}
|
||||||
|
payload['base_info'] = _build_base_info()
|
||||||
|
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f'{self.base_url}/ilink/bot/getupdates'
|
||||||
|
headers = self._build_headers()
|
||||||
|
|
||||||
|
async with session.post(
|
||||||
|
url,
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=aiohttp.ClientTimeout(total=timeout),
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ApiError(
|
||||||
|
f'OpenClaw API error {resp.status}: {text}',
|
||||||
|
status=resp.status,
|
||||||
|
)
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
|
||||||
|
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
|
||||||
|
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
|
||||||
|
except ApiError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
if 'timeout' in str(e).lower():
|
||||||
|
return GetUpdatesResponse(ret=0, msgs=[], get_updates_buf=get_updates_buf)
|
||||||
|
raise
|
||||||
|
|
||||||
|
return _parse_get_updates_response(data)
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
to_user_id: str,
|
||||||
|
item_list: list[MessageItem],
|
||||||
|
context_token: str = '',
|
||||||
|
) -> None:
|
||||||
|
"""Send a message to a user."""
|
||||||
|
items_payload = [_message_item_to_dict(item) for item in item_list]
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'msg': {
|
||||||
|
'from_user_id': '',
|
||||||
|
'to_user_id': to_user_id,
|
||||||
|
'client_id': f'langbot-{uuid.uuid4().hex[:16]}',
|
||||||
|
'message_type': WeixinMessage.TYPE_BOT,
|
||||||
|
'message_state': WeixinMessage.STATE_FINISH,
|
||||||
|
'item_list': items_payload,
|
||||||
|
'context_token': context_token or None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await self._post('ilink/bot/sendmessage', payload)
|
||||||
|
|
||||||
|
async def send_text(self, to_user_id: str, text: str, context_token: str = '') -> None:
|
||||||
|
"""Send a plain text message, automatically chunking if too long."""
|
||||||
|
chunks = _chunk_text(text)
|
||||||
|
for chunk in chunks:
|
||||||
|
item = MessageItem(type=MessageItem.TEXT, text_item=TextItem(text=chunk))
|
||||||
|
await self.send_message(to_user_id, [item], context_token)
|
||||||
|
|
||||||
|
async def get_config(self, ilink_user_id: str, context_token: str = '') -> GetConfigResponse:
|
||||||
|
"""Get bot config including typing_ticket."""
|
||||||
|
data = await self._post(
|
||||||
|
'ilink/bot/getconfig',
|
||||||
|
{'ilink_user_id': ilink_user_id, 'context_token': context_token or None},
|
||||||
|
timeout=DEFAULT_CONFIG_TIMEOUT,
|
||||||
|
)
|
||||||
|
return GetConfigResponse(
|
||||||
|
ret=data.get('ret'),
|
||||||
|
errmsg=data.get('errmsg'),
|
||||||
|
typing_ticket=data.get('typing_ticket'),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_typing(self, ilink_user_id: str, typing_ticket: str, status: int = 1) -> None:
|
||||||
|
"""Send typing indicator. status: 1=typing, 2=cancel."""
|
||||||
|
await self._post(
|
||||||
|
'ilink/bot/sendtyping',
|
||||||
|
{
|
||||||
|
'ilink_user_id': ilink_user_id,
|
||||||
|
'typing_ticket': typing_ticket,
|
||||||
|
'status': status,
|
||||||
|
},
|
||||||
|
timeout=DEFAULT_CONFIG_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop_typing(self, ilink_user_id: str, typing_ticket: str) -> None:
|
||||||
|
"""Cancel the typing indicator for a user."""
|
||||||
|
await self.send_typing(ilink_user_id, typing_ticket, status=2)
|
||||||
|
|
||||||
|
async def download_media(
|
||||||
|
self,
|
||||||
|
media: CDNMedia,
|
||||||
|
) -> bytes:
|
||||||
|
"""Download and decrypt a file from the WeChat CDN.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
media: CDNMedia object with encrypt_query_param and aes_key.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted file bytes.
|
||||||
|
"""
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
from cryptography.hazmat.primitives.padding import PKCS7
|
||||||
|
|
||||||
|
if not media.encrypt_query_param:
|
||||||
|
raise ApiError('CDN media has no encrypt_query_param', status=0)
|
||||||
|
if not media.aes_key:
|
||||||
|
raise ApiError('CDN media has no aes_key', status=0)
|
||||||
|
|
||||||
|
# Derive 16-byte AES key
|
||||||
|
# aes_key is base64-encoded; the decoded content may be:
|
||||||
|
# - raw 16 bytes (direct AES key)
|
||||||
|
# - 32-char hex string (decode hex to get 16 bytes)
|
||||||
|
raw = base64.b64decode(media.aes_key)
|
||||||
|
if len(raw) == 16:
|
||||||
|
aes_key = raw
|
||||||
|
elif len(raw) == 32:
|
||||||
|
# Hex-encoded 16-byte key
|
||||||
|
aes_key = bytes.fromhex(raw.decode('utf-8'))
|
||||||
|
else:
|
||||||
|
raise ApiError(f'Invalid AES key length: {len(raw)} (expected 16 or 32)', status=0)
|
||||||
|
|
||||||
|
# Download encrypted bytes from CDN
|
||||||
|
session = await self._get_session()
|
||||||
|
cdn_url = f'{CDN_BASE_URL}/download?encrypted_query_param={quote(media.encrypt_query_param, safe="")}'
|
||||||
|
|
||||||
|
async with session.get(cdn_url, timeout=aiohttp.ClientTimeout(total=120)) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ApiError(f'CDN download failed: {resp.status} {text}', status=resp.status)
|
||||||
|
encrypted = await resp.read()
|
||||||
|
|
||||||
|
# Decrypt AES-128-ECB with PKCS7 padding
|
||||||
|
cipher = Cipher(algorithms.AES(aes_key), modes.ECB())
|
||||||
|
decryptor = cipher.decryptor()
|
||||||
|
padded = decryptor.update(encrypted) + decryptor.finalize()
|
||||||
|
|
||||||
|
unpadder = PKCS7(128).unpadder()
|
||||||
|
return unpadder.update(padded) + unpadder.finalize()
|
||||||
|
|
||||||
|
async def upload_media(
|
||||||
|
self,
|
||||||
|
file_bytes: bytes,
|
||||||
|
to_user_id: str,
|
||||||
|
media_type: int,
|
||||||
|
) -> CDNMedia:
|
||||||
|
"""Encrypt and upload media to WeChat CDN.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_bytes: Raw file bytes to upload.
|
||||||
|
to_user_id: Recipient user ID.
|
||||||
|
media_type: 1=IMAGE, 2=VIDEO, 3=FILE, 4=VOICE.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
CDNMedia with encrypt_query_param and aes_key for use in sendMessage.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||||
|
from cryptography.hazmat.primitives.padding import PKCS7
|
||||||
|
|
||||||
|
# 1. Generate random 16-byte AES key
|
||||||
|
raw_key = os.urandom(16)
|
||||||
|
aes_key_hex = raw_key.hex() # 32-char hex string
|
||||||
|
|
||||||
|
# 2. Encode key for CDNMedia: base64(hex_string) — same for all media types
|
||||||
|
# Matches official SDK: Buffer.from(aeskey_hex).toString("base64")
|
||||||
|
encoded_key = base64.b64encode(aes_key_hex.encode('utf-8')).decode('utf-8')
|
||||||
|
|
||||||
|
# 3. Encrypt file with AES-128-ECB + PKCS7
|
||||||
|
padder = PKCS7(128).padder()
|
||||||
|
padded = padder.update(file_bytes) + padder.finalize()
|
||||||
|
cipher = Cipher(algorithms.AES(raw_key), modes.ECB())
|
||||||
|
encryptor = cipher.encryptor()
|
||||||
|
encrypted = encryptor.update(padded) + encryptor.finalize()
|
||||||
|
|
||||||
|
# 4. Get upload URL
|
||||||
|
raw_md5 = hashlib.md5(file_bytes).hexdigest()
|
||||||
|
filekey = os.urandom(16).hex() # 32-char hex, matches official SDK
|
||||||
|
|
||||||
|
upload_resp = await self.get_upload_url(
|
||||||
|
filekey=filekey,
|
||||||
|
media_type=media_type,
|
||||||
|
to_user_id=to_user_id,
|
||||||
|
rawsize=len(file_bytes),
|
||||||
|
rawfilemd5=raw_md5,
|
||||||
|
filesize=len(encrypted),
|
||||||
|
aeskey=aes_key_hex, # hex string, as expected by the API
|
||||||
|
)
|
||||||
|
|
||||||
|
if not upload_resp.upload_param:
|
||||||
|
raise ApiError('Failed to get upload URL', status=0)
|
||||||
|
|
||||||
|
# 5. Upload to CDN
|
||||||
|
# upload_param is an opaque token from the server — pass it as-is
|
||||||
|
session = await self._get_session()
|
||||||
|
cdn_url = f'{CDN_BASE_URL}/upload?encrypted_query_param={quote(upload_resp.upload_param, safe="")}&filekey={quote(filekey, safe="")}'
|
||||||
|
logger.debug(
|
||||||
|
'CDN upload: url=%s raw_size=%d encrypted_size=%d md5=%s aeskey=%s',
|
||||||
|
cdn_url,
|
||||||
|
len(file_bytes),
|
||||||
|
len(encrypted),
|
||||||
|
raw_md5,
|
||||||
|
encoded_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with session.post(
|
||||||
|
cdn_url,
|
||||||
|
data=encrypted,
|
||||||
|
headers={'Content-Type': 'application/octet-stream'},
|
||||||
|
timeout=aiohttp.ClientTimeout(total=120),
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
logger.error('CDN upload failed: status=%d url=%s body=%s', resp.status, cdn_url, text[:500])
|
||||||
|
raise ApiError(f'CDN upload failed: {resp.status} {text}', status=resp.status)
|
||||||
|
download_param = resp.headers.get('x-encrypted-param', '')
|
||||||
|
|
||||||
|
if not download_param:
|
||||||
|
raise ApiError('CDN upload succeeded but no x-encrypted-param returned', status=0)
|
||||||
|
|
||||||
|
return CDNMedia(
|
||||||
|
encrypt_query_param=download_param,
|
||||||
|
aes_key=encoded_key,
|
||||||
|
encrypt_type=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_image(
|
||||||
|
self,
|
||||||
|
to_user_id: str,
|
||||||
|
image_bytes: bytes,
|
||||||
|
context_token: str = '',
|
||||||
|
) -> None:
|
||||||
|
"""Upload an image to CDN and send it."""
|
||||||
|
media = await self.upload_media(image_bytes, to_user_id, media_type=1)
|
||||||
|
item = MessageItem(
|
||||||
|
type=MessageItem.IMAGE,
|
||||||
|
image_item=ImageItem(
|
||||||
|
media=media,
|
||||||
|
aeskey=media.aes_key,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await self.send_message(to_user_id, [item], context_token)
|
||||||
|
|
||||||
|
async def send_file(
|
||||||
|
self,
|
||||||
|
to_user_id: str,
|
||||||
|
file_bytes: bytes,
|
||||||
|
file_name: str,
|
||||||
|
context_token: str = '',
|
||||||
|
) -> None:
|
||||||
|
"""Upload a file to CDN and send it."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
media = await self.upload_media(file_bytes, to_user_id, media_type=3)
|
||||||
|
item = MessageItem(
|
||||||
|
type=MessageItem.FILE,
|
||||||
|
file_item=FileItem(
|
||||||
|
media=media,
|
||||||
|
file_name=file_name,
|
||||||
|
md5=hashlib.md5(file_bytes).hexdigest(),
|
||||||
|
len=str(len(file_bytes)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await self.send_message(to_user_id, [item], context_token)
|
||||||
|
|
||||||
|
async def send_voice(
|
||||||
|
self,
|
||||||
|
to_user_id: str,
|
||||||
|
voice_bytes: bytes,
|
||||||
|
playtime: int = 0,
|
||||||
|
context_token: str = '',
|
||||||
|
) -> None:
|
||||||
|
"""Upload a voice message to CDN and send it."""
|
||||||
|
media = await self.upload_media(voice_bytes, to_user_id, media_type=4)
|
||||||
|
item = MessageItem(
|
||||||
|
type=MessageItem.VOICE,
|
||||||
|
voice_item=VoiceItem(
|
||||||
|
media=media,
|
||||||
|
playtime=playtime,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await self.send_message(to_user_id, [item], context_token)
|
||||||
|
|
||||||
|
async def get_upload_url(
|
||||||
|
self,
|
||||||
|
filekey: str,
|
||||||
|
media_type: int,
|
||||||
|
to_user_id: str,
|
||||||
|
rawsize: int,
|
||||||
|
rawfilemd5: str,
|
||||||
|
filesize: int,
|
||||||
|
thumb_rawsize: Optional[int] = None,
|
||||||
|
thumb_rawfilemd5: Optional[str] = None,
|
||||||
|
thumb_filesize: Optional[int] = None,
|
||||||
|
aeskey: Optional[str] = None,
|
||||||
|
) -> GetUploadUrlResponse:
|
||||||
|
"""Get a pre-signed CDN upload URL."""
|
||||||
|
payload: dict = {
|
||||||
|
'filekey': filekey,
|
||||||
|
'media_type': media_type,
|
||||||
|
'to_user_id': to_user_id,
|
||||||
|
'rawsize': rawsize,
|
||||||
|
'rawfilemd5': rawfilemd5,
|
||||||
|
'filesize': filesize,
|
||||||
|
'no_need_thumb': True,
|
||||||
|
}
|
||||||
|
if thumb_rawsize is not None:
|
||||||
|
payload['thumb_rawsize'] = thumb_rawsize
|
||||||
|
if thumb_rawfilemd5 is not None:
|
||||||
|
payload['thumb_rawfilemd5'] = thumb_rawfilemd5
|
||||||
|
if thumb_filesize is not None:
|
||||||
|
payload['thumb_filesize'] = thumb_filesize
|
||||||
|
if aeskey is not None:
|
||||||
|
payload['aeskey'] = aeskey
|
||||||
|
|
||||||
|
data = await self._post('ilink/bot/getuploadurl', payload)
|
||||||
|
logger.debug('get_upload_url response: %s', data)
|
||||||
|
return GetUploadUrlResponse(
|
||||||
|
upload_param=data.get('upload_param'),
|
||||||
|
thumb_upload_param=data.get('thumb_upload_param'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
# QR Code Login
|
||||||
|
# -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def fetch_qrcode(self, bot_type: str = DEFAULT_BOT_TYPE) -> QRCodeResponse:
|
||||||
|
"""Fetch a QR code for WeChat login authorization (GET, no auth needed)."""
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f'{self.base_url}/ilink/bot/get_bot_qrcode?bot_type={bot_type}'
|
||||||
|
|
||||||
|
async with session.get(url, timeout=aiohttp.ClientTimeout(total=DEFAULT_API_TIMEOUT)) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ApiError(
|
||||||
|
f'Failed to fetch QR code: {resp.status} {text}',
|
||||||
|
status=resp.status,
|
||||||
|
)
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
'fetch_qrcode response: qrcode=%s, img=%s', data.get('qrcode'), bool(data.get('qrcode_img_content'))
|
||||||
|
)
|
||||||
|
|
||||||
|
return QRCodeResponse(
|
||||||
|
qrcode=data.get('qrcode'),
|
||||||
|
qrcode_img_content=data.get('qrcode_img_content'),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _fetch_qr_image_base64(self, url: str) -> str:
|
||||||
|
"""Generate a QR code image from the URL and return a data URI string.
|
||||||
|
|
||||||
|
The qrcode_img_content URL points to an HTML page (not a raw image),
|
||||||
|
so we generate the QR code locally using the qrcode library.
|
||||||
|
"""
|
||||||
|
import qrcode
|
||||||
|
|
||||||
|
qr = qrcode.QRCode(error_correction=qrcode.constants.ERROR_CORRECT_L)
|
||||||
|
qr.add_data(url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
img = qr.make_image(fill_color='black', back_color='white')
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
img.save(buf, format='PNG')
|
||||||
|
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
return f'data:image/png;base64,{b64}'
|
||||||
|
|
||||||
|
async def poll_qrcode_status(self, qrcode: str) -> QRStatusResponse:
|
||||||
|
"""Long-poll the QR code scan status (GET with iLink-App-ClientVersion header)."""
|
||||||
|
session = await self._get_session()
|
||||||
|
url = f'{self.base_url}/ilink/bot/get_qrcode_status?qrcode={quote(qrcode, safe="")}'
|
||||||
|
headers = {'iLink-App-ClientVersion': '1'}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with session.get(
|
||||||
|
url, headers=headers, timeout=aiohttp.ClientTimeout(total=DEFAULT_QR_POLL_TIMEOUT)
|
||||||
|
) as resp:
|
||||||
|
if resp.status != 200:
|
||||||
|
text = await resp.text()
|
||||||
|
raise ApiError(
|
||||||
|
f'Failed to poll QR status: {resp.status} {text}',
|
||||||
|
status=resp.status,
|
||||||
|
)
|
||||||
|
data = await resp.json(content_type=None)
|
||||||
|
logger.debug('QR status poll response: %s', data)
|
||||||
|
except (asyncio.TimeoutError, aiohttp.ServerTimeoutError):
|
||||||
|
return QRStatusResponse(status='wait')
|
||||||
|
|
||||||
|
return QRStatusResponse(
|
||||||
|
status=data.get('status'),
|
||||||
|
bot_token=data.get('bot_token'),
|
||||||
|
ilink_bot_id=data.get('ilink_bot_id'),
|
||||||
|
baseurl=data.get('baseurl'),
|
||||||
|
ilink_user_id=data.get('ilink_user_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def login(
|
||||||
|
self,
|
||||||
|
max_retries: int = 5,
|
||||||
|
poll_timeout_ms: int = 480_000,
|
||||||
|
on_qrcode: Optional[typing.Callable[[str, str], typing.Any]] = None,
|
||||||
|
on_status: Optional[typing.Callable[[str], typing.Any]] = None,
|
||||||
|
) -> LoginResult:
|
||||||
|
"""Complete QR code login flow with auto-retry on expiry.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_retries: Max number of QR code refreshes on expiry.
|
||||||
|
poll_timeout_ms: Timeout per QR code in milliseconds.
|
||||||
|
on_qrcode: Callback(qr_image_base64, qr_url) called each time a
|
||||||
|
new QR code is fetched. Use this to display the QR code.
|
||||||
|
on_status: Callback(status_str) called on each status poll change.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LoginResult with token, base_url, and account_id.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ApiError: On unrecoverable API errors.
|
||||||
|
Exception: If all retries are exhausted.
|
||||||
|
"""
|
||||||
|
last_qr_base64: Optional[str] = None
|
||||||
|
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
qr_resp = await self.fetch_qrcode()
|
||||||
|
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
||||||
|
raise ApiError('Failed to get QR code from server', status=0)
|
||||||
|
|
||||||
|
# Convert QR image to base64 and notify caller
|
||||||
|
last_qr_base64 = await self._fetch_qr_image_base64(qr_resp.qrcode_img_content)
|
||||||
|
if on_qrcode:
|
||||||
|
try:
|
||||||
|
result = on_qrcode(last_qr_base64, qr_resp.qrcode_img_content)
|
||||||
|
if asyncio.iscoroutine(result) or asyncio.isfuture(result):
|
||||||
|
await result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('on_qrcode callback error: %s', e)
|
||||||
|
|
||||||
|
# Poll until confirmed / expired / timeout
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
deadline = loop.time() + poll_timeout_ms / 1000.0
|
||||||
|
|
||||||
|
while loop.time() < deadline:
|
||||||
|
try:
|
||||||
|
status_resp = await self.poll_qrcode_status(qr_resp.qrcode)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error('Error polling QR status: %s', e)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if on_status:
|
||||||
|
try:
|
||||||
|
cb_result = on_status(status_resp.status or 'unknown')
|
||||||
|
if asyncio.iscoroutine(cb_result) or asyncio.isfuture(cb_result):
|
||||||
|
await cb_result
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning('on_status callback error: %s', e)
|
||||||
|
|
||||||
|
if status_resp.status == 'confirmed' and status_resp.bot_token:
|
||||||
|
new_base_url = status_resp.baseurl or self.base_url
|
||||||
|
# Update this client instance as well
|
||||||
|
self.token = status_resp.bot_token
|
||||||
|
self.base_url = new_base_url.rstrip('/')
|
||||||
|
return LoginResult(
|
||||||
|
token=status_resp.bot_token,
|
||||||
|
base_url=new_base_url,
|
||||||
|
account_id=status_resp.ilink_bot_id or '',
|
||||||
|
qr_image_base64=last_qr_base64,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status_resp.status == 'expired':
|
||||||
|
break # retry with a new QR code
|
||||||
|
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
else:
|
||||||
|
# While-loop ended without break → poll timeout, treat as expired
|
||||||
|
pass
|
||||||
|
|
||||||
|
remaining = max_retries - attempt - 1
|
||||||
|
if remaining > 0:
|
||||||
|
logger.info('QR code expired, refreshing... (%d retries left)', remaining)
|
||||||
|
else:
|
||||||
|
raise ApiError('QR code login failed: max retries exceeded', status=0)
|
||||||
|
|
||||||
|
# Should not reach here, but just in case
|
||||||
|
raise ApiError('QR code login failed', status=0)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parsing helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cdn_media(data: Optional[dict]) -> Optional[CDNMedia]:
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
return CDNMedia(
|
||||||
|
encrypt_query_param=data.get('encrypt_query_param'),
|
||||||
|
aes_key=data.get('aes_key'),
|
||||||
|
encrypt_type=data.get('encrypt_type'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_message_item(data: dict) -> MessageItem:
|
||||||
|
item = MessageItem(
|
||||||
|
type=data.get('type'),
|
||||||
|
create_time_ms=data.get('create_time_ms'),
|
||||||
|
update_time_ms=data.get('update_time_ms'),
|
||||||
|
is_completed=data.get('is_completed'),
|
||||||
|
msg_id=data.get('msg_id'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('text_item'):
|
||||||
|
item.text_item = TextItem(text=data['text_item'].get('text'))
|
||||||
|
|
||||||
|
if data.get('image_item'):
|
||||||
|
img = data['image_item']
|
||||||
|
item.image_item = ImageItem(
|
||||||
|
media=_parse_cdn_media(img.get('media')),
|
||||||
|
thumb_media=_parse_cdn_media(img.get('thumb_media')),
|
||||||
|
aeskey=img.get('aeskey'),
|
||||||
|
url=img.get('url'),
|
||||||
|
mid_size=img.get('mid_size'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('voice_item'):
|
||||||
|
v = data['voice_item']
|
||||||
|
item.voice_item = VoiceItem(
|
||||||
|
media=_parse_cdn_media(v.get('media')),
|
||||||
|
encode_type=v.get('encode_type'),
|
||||||
|
playtime=v.get('playtime'),
|
||||||
|
text=v.get('text'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('file_item'):
|
||||||
|
f = data['file_item']
|
||||||
|
item.file_item = FileItem(
|
||||||
|
media=_parse_cdn_media(f.get('media')),
|
||||||
|
file_name=f.get('file_name'),
|
||||||
|
md5=f.get('md5'),
|
||||||
|
len=f.get('len'),
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('video_item'):
|
||||||
|
vid = data['video_item']
|
||||||
|
item.video_item = VideoItem(
|
||||||
|
media=_parse_cdn_media(vid.get('media')),
|
||||||
|
video_size=vid.get('video_size'),
|
||||||
|
play_length=vid.get('play_length'),
|
||||||
|
video_md5=vid.get('video_md5'),
|
||||||
|
thumb_media=_parse_cdn_media(vid.get('thumb_media')),
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get('ref_msg'):
|
||||||
|
ref = data['ref_msg']
|
||||||
|
item.ref_msg = RefMessage(
|
||||||
|
title=ref.get('title'),
|
||||||
|
message_item=_parse_message_item(ref['message_item']) if ref.get('message_item') else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_weixin_message(data: dict) -> WeixinMessage:
|
||||||
|
msg = WeixinMessage(
|
||||||
|
seq=data.get('seq'),
|
||||||
|
message_id=data.get('message_id'),
|
||||||
|
from_user_id=data.get('from_user_id'),
|
||||||
|
to_user_id=data.get('to_user_id'),
|
||||||
|
client_id=data.get('client_id'),
|
||||||
|
create_time_ms=data.get('create_time_ms'),
|
||||||
|
session_id=data.get('session_id'),
|
||||||
|
group_id=data.get('group_id'),
|
||||||
|
message_type=data.get('message_type'),
|
||||||
|
message_state=data.get('message_state'),
|
||||||
|
context_token=data.get('context_token'),
|
||||||
|
)
|
||||||
|
if data.get('item_list'):
|
||||||
|
msg.item_list = [_parse_message_item(item) for item in data['item_list']]
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_get_updates_response(data: dict) -> GetUpdatesResponse:
|
||||||
|
resp = GetUpdatesResponse(
|
||||||
|
ret=data.get('ret'),
|
||||||
|
errcode=data.get('errcode'),
|
||||||
|
errmsg=data.get('errmsg'),
|
||||||
|
get_updates_buf=data.get('get_updates_buf'),
|
||||||
|
longpolling_timeout_ms=data.get('longpolling_timeout_ms'),
|
||||||
|
)
|
||||||
|
if data.get('msgs'):
|
||||||
|
resp.msgs = [_parse_weixin_message(m) for m in data['msgs']]
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _cdn_media_to_dict(media: Optional[CDNMedia]) -> Optional[dict]:
|
||||||
|
if not media:
|
||||||
|
return None
|
||||||
|
d: dict = {}
|
||||||
|
if media.encrypt_query_param is not None:
|
||||||
|
d['encrypt_query_param'] = media.encrypt_query_param
|
||||||
|
if media.aes_key is not None:
|
||||||
|
d['aes_key'] = media.aes_key
|
||||||
|
if media.encrypt_type is not None:
|
||||||
|
d['encrypt_type'] = media.encrypt_type
|
||||||
|
return d or None
|
||||||
|
|
||||||
|
|
||||||
|
def _message_item_to_dict(item: MessageItem) -> dict:
|
||||||
|
d: dict = {'type': item.type}
|
||||||
|
|
||||||
|
if item.text_item:
|
||||||
|
d['text_item'] = {'text': item.text_item.text}
|
||||||
|
|
||||||
|
if item.image_item:
|
||||||
|
img_d: dict = {}
|
||||||
|
if item.image_item.media:
|
||||||
|
img_d['media'] = _cdn_media_to_dict(item.image_item.media)
|
||||||
|
if item.image_item.mid_size is not None:
|
||||||
|
img_d['mid_size'] = item.image_item.mid_size
|
||||||
|
d['image_item'] = img_d
|
||||||
|
|
||||||
|
if item.voice_item:
|
||||||
|
voice_d: dict = {}
|
||||||
|
if item.voice_item.media:
|
||||||
|
voice_d['media'] = _cdn_media_to_dict(item.voice_item.media)
|
||||||
|
if item.voice_item.playtime is not None:
|
||||||
|
voice_d['playtime'] = item.voice_item.playtime
|
||||||
|
d['voice_item'] = voice_d
|
||||||
|
|
||||||
|
if item.file_item:
|
||||||
|
file_d: dict = {}
|
||||||
|
if item.file_item.media:
|
||||||
|
file_d['media'] = _cdn_media_to_dict(item.file_item.media)
|
||||||
|
if item.file_item.file_name:
|
||||||
|
file_d['file_name'] = item.file_item.file_name
|
||||||
|
if item.file_item.len:
|
||||||
|
file_d['len'] = item.file_item.len
|
||||||
|
d['file_item'] = file_d
|
||||||
|
|
||||||
|
if item.video_item:
|
||||||
|
vid_d: dict = {}
|
||||||
|
if item.video_item.media:
|
||||||
|
vid_d['media'] = _cdn_media_to_dict(item.video_item.media)
|
||||||
|
if item.video_item.video_size is not None:
|
||||||
|
vid_d['video_size'] = item.video_item.video_size
|
||||||
|
d['video_item'] = vid_d
|
||||||
|
|
||||||
|
return d
|
||||||
200
src/langbot/libs/openclaw_weixin_api/types.py
Normal file
200
src/langbot/libs/openclaw_weixin_api/types.py
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
"""Type definitions for the OpenClaw WeChat API, mirroring the upstream protocol."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
SESSION_EXPIRED_ERRCODE = -14
|
||||||
|
|
||||||
|
|
||||||
|
class ApiError(Exception):
|
||||||
|
"""Structured error raised by the OpenClaw WeChat API."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
status: int = 0,
|
||||||
|
code: int | None = None,
|
||||||
|
payload: Any = None,
|
||||||
|
):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
self.code = code
|
||||||
|
self.payload = payload
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_session_expired(self) -> bool:
|
||||||
|
return self.code == SESSION_EXPIRED_ERRCODE
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CDNMedia:
|
||||||
|
encrypt_query_param: Optional[str] = None
|
||||||
|
aes_key: Optional[str] = None
|
||||||
|
encrypt_type: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TextItem:
|
||||||
|
text: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ImageItem:
|
||||||
|
media: Optional[CDNMedia] = None
|
||||||
|
thumb_media: Optional[CDNMedia] = None
|
||||||
|
aeskey: Optional[str] = None
|
||||||
|
url: Optional[str] = None
|
||||||
|
mid_size: Optional[int] = None
|
||||||
|
thumb_size: Optional[int] = None
|
||||||
|
thumb_height: Optional[int] = None
|
||||||
|
thumb_width: Optional[int] = None
|
||||||
|
hd_size: Optional[int] = None
|
||||||
|
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VoiceItem:
|
||||||
|
media: Optional[CDNMedia] = None
|
||||||
|
encode_type: Optional[int] = None
|
||||||
|
bits_per_sample: Optional[int] = None
|
||||||
|
sample_rate: Optional[int] = None
|
||||||
|
playtime: Optional[int] = None
|
||||||
|
text: Optional[str] = None
|
||||||
|
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class FileItem:
|
||||||
|
media: Optional[CDNMedia] = None
|
||||||
|
file_name: Optional[str] = None
|
||||||
|
md5: Optional[str] = None
|
||||||
|
len: Optional[str] = None
|
||||||
|
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VideoItem:
|
||||||
|
media: Optional[CDNMedia] = None
|
||||||
|
video_size: Optional[int] = None
|
||||||
|
play_length: Optional[int] = None
|
||||||
|
video_md5: Optional[str] = None
|
||||||
|
thumb_media: Optional[CDNMedia] = None
|
||||||
|
thumb_size: Optional[int] = None
|
||||||
|
thumb_height: Optional[int] = None
|
||||||
|
thumb_width: Optional[int] = None
|
||||||
|
_downloaded_bytes: Optional[bytes] = field(default=None, repr=False)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RefMessage:
|
||||||
|
message_item: Optional[MessageItem] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MessageItem:
|
||||||
|
"""A single content item inside a WeixinMessage."""
|
||||||
|
|
||||||
|
# Item types
|
||||||
|
NONE = 0
|
||||||
|
TEXT = 1
|
||||||
|
IMAGE = 2
|
||||||
|
VOICE = 3
|
||||||
|
FILE = 4
|
||||||
|
VIDEO = 5
|
||||||
|
|
||||||
|
type: Optional[int] = None
|
||||||
|
create_time_ms: Optional[int] = None
|
||||||
|
update_time_ms: Optional[int] = None
|
||||||
|
is_completed: Optional[bool] = None
|
||||||
|
msg_id: Optional[str] = None
|
||||||
|
ref_msg: Optional[RefMessage] = None
|
||||||
|
text_item: Optional[TextItem] = None
|
||||||
|
image_item: Optional[ImageItem] = None
|
||||||
|
voice_item: Optional[VoiceItem] = None
|
||||||
|
file_item: Optional[FileItem] = None
|
||||||
|
video_item: Optional[VideoItem] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WeixinMessage:
|
||||||
|
"""Unified message from getUpdates or for sendMessage."""
|
||||||
|
|
||||||
|
# Message types
|
||||||
|
TYPE_USER = 1
|
||||||
|
TYPE_BOT = 2
|
||||||
|
|
||||||
|
# Message states
|
||||||
|
STATE_NEW = 0
|
||||||
|
STATE_GENERATING = 1
|
||||||
|
STATE_FINISH = 2
|
||||||
|
|
||||||
|
seq: Optional[int] = None
|
||||||
|
message_id: Optional[int] = None
|
||||||
|
from_user_id: Optional[str] = None
|
||||||
|
to_user_id: Optional[str] = None
|
||||||
|
client_id: Optional[str] = None
|
||||||
|
create_time_ms: Optional[int] = None
|
||||||
|
update_time_ms: Optional[int] = None
|
||||||
|
delete_time_ms: Optional[int] = None
|
||||||
|
session_id: Optional[str] = None
|
||||||
|
group_id: Optional[str] = None
|
||||||
|
message_type: Optional[int] = None
|
||||||
|
message_state: Optional[int] = None
|
||||||
|
item_list: Optional[list[MessageItem]] = None
|
||||||
|
context_token: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GetUpdatesResponse:
|
||||||
|
ret: Optional[int] = None
|
||||||
|
errcode: Optional[int] = None
|
||||||
|
errmsg: Optional[str] = None
|
||||||
|
msgs: list[WeixinMessage] = field(default_factory=list)
|
||||||
|
get_updates_buf: Optional[str] = None
|
||||||
|
longpolling_timeout_ms: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GetConfigResponse:
|
||||||
|
ret: Optional[int] = None
|
||||||
|
errmsg: Optional[str] = None
|
||||||
|
typing_ticket: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GetUploadUrlResponse:
|
||||||
|
upload_param: Optional[str] = None
|
||||||
|
thumb_upload_param: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QRCodeResponse:
|
||||||
|
"""Response from get_bot_qrcode endpoint."""
|
||||||
|
|
||||||
|
qrcode: Optional[str] = None
|
||||||
|
qrcode_img_content: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class QRStatusResponse:
|
||||||
|
"""Response from get_qrcode_status endpoint."""
|
||||||
|
|
||||||
|
status: Optional[str] = None # "wait" | "scaned" | "confirmed" | "expired"
|
||||||
|
bot_token: Optional[str] = None
|
||||||
|
ilink_bot_id: Optional[str] = None
|
||||||
|
baseurl: Optional[str] = None
|
||||||
|
ilink_user_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class LoginResult:
|
||||||
|
"""Result returned by the login flow."""
|
||||||
|
|
||||||
|
token: str
|
||||||
|
base_url: str
|
||||||
|
account_id: str
|
||||||
|
qr_image_base64: Optional[str] = None # data URI of the last QR code shown
|
||||||
@@ -6,7 +6,8 @@ import traceback
|
|||||||
import uuid
|
import uuid
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable, Optional
|
import re
|
||||||
|
from typing import Any, Callable, Optional, Tuple
|
||||||
from urllib.parse import unquote
|
from urllib.parse import unquote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -63,6 +64,9 @@ class StreamSession:
|
|||||||
# 缓存最近一次片段,处理重试或超时兜底
|
# 缓存最近一次片段,处理重试或超时兜底
|
||||||
last_chunk: Optional[StreamChunk] = None
|
last_chunk: Optional[StreamChunk] = None
|
||||||
|
|
||||||
|
# 反馈 ID,用于接收用户点赞/点踩反馈
|
||||||
|
feedback_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class StreamSessionManager:
|
class StreamSessionManager:
|
||||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
||||||
@@ -73,6 +77,7 @@ class StreamSessionManager:
|
|||||||
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
||||||
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
||||||
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
|
self._msg_index: dict[str, str] = {} # msgid -> stream_id 映射,便于流水线根据消息 ID 找到会话
|
||||||
|
self._feedback_index: dict[str, str] = {} # feedback_id -> stream_id 映射
|
||||||
|
|
||||||
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
|
def get_stream_id_by_msg(self, msg_id: str) -> Optional[str]:
|
||||||
if not msg_id:
|
if not msg_id:
|
||||||
@@ -82,6 +87,32 @@ class StreamSessionManager:
|
|||||||
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
||||||
return self._sessions.get(stream_id)
|
return self._sessions.get(stream_id)
|
||||||
|
|
||||||
|
def get_session_by_feedback_id(self, feedback_id: str) -> Optional[StreamSession]:
|
||||||
|
"""根据 feedback_id 查找会话。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feedback_id: 企业微信反馈事件中的反馈 ID。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Optional[StreamSession]: 找到的会话实例,未找到返回 None。
|
||||||
|
"""
|
||||||
|
if not feedback_id:
|
||||||
|
return None
|
||||||
|
stream_id = self._feedback_index.get(feedback_id)
|
||||||
|
if stream_id:
|
||||||
|
return self._sessions.get(stream_id)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def register_feedback_id(self, stream_id: str, feedback_id: str) -> None:
|
||||||
|
"""注册 feedback_id 与 stream_id 的映射。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
stream_id: 企业微信流式会话 ID。
|
||||||
|
feedback_id: 反馈 ID。
|
||||||
|
"""
|
||||||
|
if feedback_id and stream_id:
|
||||||
|
self._feedback_index[feedback_id] = stream_id
|
||||||
|
|
||||||
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
|
def create_or_get(self, msg_json: dict[str, Any]) -> tuple[StreamSession, bool]:
|
||||||
"""根据企业微信回调创建或获取会话。
|
"""根据企业微信回调创建或获取会话。
|
||||||
|
|
||||||
@@ -199,6 +230,366 @@ class StreamSessionManager:
|
|||||||
self._msg_index.pop(msg_id, None)
|
self._msg_index.pop(msg_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
||||||
|
"""Decrypt AES-256-CBC encrypted file data.
|
||||||
|
|
||||||
|
Aligned with the official WeCom AI Bot Python SDK (crypto_utils.py).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
encrypted_data: The raw encrypted bytes.
|
||||||
|
aes_key_str: Base64-encoded AES key (may lack padding).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted bytes with PKCS#7 padding removed.
|
||||||
|
"""
|
||||||
|
if not encrypted_data:
|
||||||
|
raise ValueError('encrypted_data is empty')
|
||||||
|
if not aes_key_str:
|
||||||
|
raise ValueError('aes_key is empty')
|
||||||
|
|
||||||
|
# Python's base64.b64decode requires proper padding (length % 4 == 0).
|
||||||
|
# Node.js Buffer.from tolerates missing '=', so we must pad manually.
|
||||||
|
remainder = len(aes_key_str) % 4
|
||||||
|
if remainder != 0:
|
||||||
|
aes_key_str = aes_key_str + '=' * (4 - remainder)
|
||||||
|
key = base64.b64decode(aes_key_str)
|
||||||
|
|
||||||
|
iv = key[:16]
|
||||||
|
|
||||||
|
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||||
|
|
||||||
|
# Ensure encrypted data is aligned to AES block size (16 bytes).
|
||||||
|
# Node.js setAutoPadding(false) silently handles unaligned data,
|
||||||
|
# but PyCryptodome will raise an error.
|
||||||
|
block_size = 16
|
||||||
|
data_remainder = len(encrypted_data) % block_size
|
||||||
|
if data_remainder != 0:
|
||||||
|
encrypted_data = encrypted_data + b'\x00' * (block_size - data_remainder)
|
||||||
|
|
||||||
|
decrypted = cipher.decrypt(encrypted_data)
|
||||||
|
|
||||||
|
# Remove PKCS#7 padding with validation
|
||||||
|
if len(decrypted) == 0:
|
||||||
|
raise ValueError('Decrypted data is empty')
|
||||||
|
|
||||||
|
pad_len = decrypted[-1]
|
||||||
|
if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted):
|
||||||
|
raise ValueError(f'Invalid PKCS#7 padding value: {pad_len}')
|
||||||
|
|
||||||
|
# Verify all padding bytes are consistent
|
||||||
|
for i in range(len(decrypted) - pad_len, len(decrypted)):
|
||||||
|
if decrypted[i] != pad_len:
|
||||||
|
raise ValueError('Invalid PKCS#7 padding: padding bytes mismatch')
|
||||||
|
|
||||||
|
return decrypted[: len(decrypted) - pad_len]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_filename(content_disposition: str) -> Optional[str]:
|
||||||
|
"""Extract filename from a Content-Disposition header value."""
|
||||||
|
if not content_disposition:
|
||||||
|
return None
|
||||||
|
# RFC 5987: filename*=UTF-8''xxx
|
||||||
|
utf8_match = re.search(r"filename\*=UTF-8''([^;\s]+)", content_disposition, re.IGNORECASE)
|
||||||
|
if utf8_match:
|
||||||
|
return unquote(utf8_match.group(1))
|
||||||
|
# Standard: filename="xxx" or filename=xxx
|
||||||
|
match = re.search(r'filename="?([^";\s]+)"?', content_disposition, re.IGNORECASE)
|
||||||
|
if match:
|
||||||
|
return unquote(match.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _bytes_to_data_uri(data: bytes) -> str:
|
||||||
|
"""Convert raw bytes to a data URI with auto-detected MIME type."""
|
||||||
|
if data.startswith(b'\xff\xd8'):
|
||||||
|
mime_type = 'image/jpeg'
|
||||||
|
elif data.startswith(b'\x89PNG'):
|
||||||
|
mime_type = 'image/png'
|
||||||
|
elif data.startswith((b'GIF87a', b'GIF89a')):
|
||||||
|
mime_type = 'image/gif'
|
||||||
|
elif data.startswith(b'BM'):
|
||||||
|
mime_type = 'image/bmp'
|
||||||
|
elif data.startswith(b'II*\x00') or data.startswith(b'MM\x00*'):
|
||||||
|
mime_type = 'image/tiff'
|
||||||
|
elif data[:4] == b'%PDF':
|
||||||
|
mime_type = 'application/pdf'
|
||||||
|
elif data[:4] == b'PK\x03\x04':
|
||||||
|
mime_type = 'application/zip'
|
||||||
|
else:
|
||||||
|
mime_type = 'application/octet-stream'
|
||||||
|
|
||||||
|
base64_str = base64.b64encode(data).decode('utf-8')
|
||||||
|
return f'data:{mime_type};base64,{base64_str}'
|
||||||
|
|
||||||
|
|
||||||
|
async def download_encrypted_file(
|
||||||
|
download_url: str, aes_key: str, logger: EventLogger
|
||||||
|
) -> Tuple[Optional[bytes], Optional[str]]:
|
||||||
|
"""Download an AES-encrypted file from WeChat Work and decrypt it.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download_url: The encrypted file download URL.
|
||||||
|
aes_key: The AES key for decryption (base64-encoded, per-message aeskey
|
||||||
|
or platform EncodingAESKey).
|
||||||
|
logger: Logger instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A tuple of (decrypted_bytes, filename) or (None, None) on failure.
|
||||||
|
"""
|
||||||
|
if not download_url:
|
||||||
|
return None, None
|
||||||
|
if not aes_key:
|
||||||
|
await logger.error('download_encrypted_file: aes_key is empty, cannot decrypt')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
filename: Optional[str] = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
response = await client.get(download_url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
await logger.error(f'Failed to download file (HTTP {response.status_code}): {response.text[:200]}')
|
||||||
|
return None, None
|
||||||
|
encrypted_bytes = response.content
|
||||||
|
filename = _extract_filename(response.headers.get('content-disposition', ''))
|
||||||
|
except Exception:
|
||||||
|
await logger.error(f'Failed to download file: {traceback.format_exc()}')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
try:
|
||||||
|
decrypted = _decrypt_file(encrypted_bytes, aes_key)
|
||||||
|
return decrypted, filename
|
||||||
|
except Exception:
|
||||||
|
await logger.error(f'Failed to decrypt file: {traceback.format_exc()}')
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_wecom_bot_message(
|
||||||
|
msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Parse a decrypted WeChat Work AI Bot message JSON into a unified message dict.
|
||||||
|
|
||||||
|
This is the shared message parsing logic used by both webhook and WebSocket modes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_json: The decrypted message JSON from WeChat Work.
|
||||||
|
encoding_aes_key: AES key for file decryption.
|
||||||
|
logger: Logger instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict suitable for constructing a WecomBotEvent.
|
||||||
|
"""
|
||||||
|
message_data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
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'
|
||||||
|
|
||||||
|
max_inline_file_size = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
async def _safe_download(url: str, per_msg_aeskey: str = '') -> Tuple[Optional[bytes], Optional[str]]:
|
||||||
|
"""Download and decrypt a file, preferring per-message aeskey over platform key."""
|
||||||
|
if not url:
|
||||||
|
return None, None
|
||||||
|
key = per_msg_aeskey or encoding_aes_key
|
||||||
|
if not key:
|
||||||
|
await logger.warning('No AES key available for file decryption, skipping download')
|
||||||
|
return None, None
|
||||||
|
return await download_encrypted_file(url, key, logger)
|
||||||
|
|
||||||
|
async def _safe_download_as_data_uri(url: str, per_msg_aeskey: str = '') -> Optional[str]:
|
||||||
|
"""Download, decrypt, and convert to data URI for backward compatibility."""
|
||||||
|
data, _filename = await _safe_download(url, per_msg_aeskey)
|
||||||
|
if data:
|
||||||
|
return _bytes_to_data_uri(data)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if msg_type == 'text':
|
||||||
|
message_data['content'] = msg_json.get('text', {}).get('content')
|
||||||
|
elif msg_type == 'markdown':
|
||||||
|
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
|
||||||
|
'content', ''
|
||||||
|
)
|
||||||
|
elif msg_type == 'image':
|
||||||
|
image_info = msg_json.get('image', {})
|
||||||
|
picurl = image_info.get('url', '')
|
||||||
|
per_msg_aeskey = image_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(picurl, per_msg_aeskey)
|
||||||
|
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')
|
||||||
|
per_msg_aeskey = voice_info.get('aeskey', '')
|
||||||
|
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_as_data_uri(download_url, per_msg_aeskey)
|
||||||
|
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')
|
||||||
|
per_msg_aeskey = video_info.get('aeskey', '')
|
||||||
|
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_as_data_uri(download_url, per_msg_aeskey)
|
||||||
|
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')
|
||||||
|
per_msg_aeskey = file_info.get('aeskey', '')
|
||||||
|
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_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
||||||
|
if file_bytes:
|
||||||
|
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||||
|
if dl_filename and not file_data.get('filename'):
|
||||||
|
file_data['filename'] = dl_filename
|
||||||
|
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 = []
|
||||||
|
images = []
|
||||||
|
files = []
|
||||||
|
voices = []
|
||||||
|
videos = []
|
||||||
|
links = []
|
||||||
|
for item in items:
|
||||||
|
item_type = item.get('msgtype')
|
||||||
|
if item_type == 'text':
|
||||||
|
texts.append(item.get('text', {}).get('content', ''))
|
||||||
|
elif item_type == 'image':
|
||||||
|
img_info = item.get('image', {})
|
||||||
|
img_url = img_info.get('url')
|
||||||
|
img_aeskey = img_info.get('aeskey', '')
|
||||||
|
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||||
|
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')
|
||||||
|
item_aeskey = file_info.get('aeskey', '')
|
||||||
|
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_bytes, dl_filename = await _safe_download(download_url, item_aeskey)
|
||||||
|
if file_bytes:
|
||||||
|
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||||
|
if dl_filename and not file_data.get('filename'):
|
||||||
|
file_data['filename'] = dl_filename
|
||||||
|
files.append(file_data)
|
||||||
|
elif item_type == 'voice':
|
||||||
|
voice_info = item.get('voice', {}) or {}
|
||||||
|
download_url = voice_info.get('url')
|
||||||
|
item_aeskey = voice_info.get('aeskey', '')
|
||||||
|
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_as_data_uri(download_url, item_aeskey)
|
||||||
|
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')
|
||||||
|
item_aeskey = video_info.get('aeskey', '')
|
||||||
|
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_as_data_uri(download_url, item_aeskey)
|
||||||
|
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)
|
||||||
|
if images:
|
||||||
|
message_data['images'] = images
|
||||||
|
message_data['picurl'] = images[0]
|
||||||
|
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
|
||||||
|
|
||||||
|
from_info = msg_json.get('from', {})
|
||||||
|
message_data['userid'] = from_info.get('userid', '')
|
||||||
|
message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
|
||||||
|
|
||||||
|
if msg_json.get('chattype', '') == 'group':
|
||||||
|
message_data['chatid'] = msg_json.get('chatid', '')
|
||||||
|
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
|
||||||
|
|
||||||
|
message_data['msgid'] = msg_json.get('msgid', '')
|
||||||
|
|
||||||
|
if msg_json.get('aibotid'):
|
||||||
|
message_data['aibotid'] = msg_json.get('aibotid', '')
|
||||||
|
|
||||||
|
return message_data
|
||||||
|
|
||||||
|
|
||||||
class WecomBotClient:
|
class WecomBotClient:
|
||||||
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False):
|
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False):
|
||||||
"""企业微信智能机器人客户端。
|
"""企业微信智能机器人客户端。
|
||||||
@@ -236,14 +627,27 @@ class WecomBotClient:
|
|||||||
self.stream_sessions = StreamSessionManager(logger=logger)
|
self.stream_sessions = StreamSessionManager(logger=logger)
|
||||||
self.stream_poll_timeout = 0.5
|
self.stream_poll_timeout = 0.5
|
||||||
|
|
||||||
|
self._feedback_callback: Optional[Callable] = None
|
||||||
|
|
||||||
|
def set_feedback_callback(self, callback: Callable) -> None:
|
||||||
|
"""设置反馈回调函数。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: 反馈回调函数,签名: async def callback(feedback_id, feedback_type, feedback_content, inaccurate_reasons, session)
|
||||||
|
"""
|
||||||
|
self._feedback_callback = callback
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _build_stream_payload(stream_id: str, content: str, finish: bool) -> dict[str, Any]:
|
def _build_stream_payload(
|
||||||
|
stream_id: str, content: str, finish: bool, feedback_id: Optional[str] = None
|
||||||
|
) -> dict[str, Any]:
|
||||||
"""按照企业微信协议拼装返回报文。
|
"""按照企业微信协议拼装返回报文。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
stream_id: 企业微信会话 ID。
|
stream_id: 企业微信会话 ID。
|
||||||
content: 推送的文本内容。
|
content: 推送的文本内容。
|
||||||
finish: 是否为最终片段。
|
finish: 是否为最终片段。
|
||||||
|
feedback_id: 反馈 ID,用于接收用户点赞/点踩反馈。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict[str, Any]: 可直接加密返回的 payload。
|
dict[str, Any]: 可直接加密返回的 payload。
|
||||||
@@ -251,13 +655,16 @@ class WecomBotClient:
|
|||||||
Example:
|
Example:
|
||||||
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
||||||
"""
|
"""
|
||||||
|
stream_payload = {
|
||||||
|
'id': stream_id,
|
||||||
|
'finish': finish,
|
||||||
|
'content': content,
|
||||||
|
}
|
||||||
|
if feedback_id:
|
||||||
|
stream_payload['feedback'] = {'id': feedback_id}
|
||||||
return {
|
return {
|
||||||
'msgtype': 'stream',
|
'msgtype': 'stream',
|
||||||
'stream': {
|
'stream': stream_payload,
|
||||||
'id': stream_id,
|
|
||||||
'finish': finish,
|
|
||||||
'content': content,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
@@ -313,9 +720,14 @@ class WecomBotClient:
|
|||||||
"""
|
"""
|
||||||
session, is_new = self.stream_sessions.create_or_get(msg_json)
|
session, is_new = self.stream_sessions.create_or_get(msg_json)
|
||||||
|
|
||||||
|
feedback_id = str(uuid.uuid4())
|
||||||
|
session.feedback_id = feedback_id
|
||||||
|
self.stream_sessions.register_feedback_id(session.stream_id, feedback_id)
|
||||||
|
|
||||||
message_data = await self.get_message(msg_json)
|
message_data = await self.get_message(msg_json)
|
||||||
if message_data:
|
if message_data:
|
||||||
message_data['stream_id'] = session.stream_id
|
message_data['stream_id'] = session.stream_id
|
||||||
|
message_data['feedback_id'] = feedback_id
|
||||||
try:
|
try:
|
||||||
event = wecombotevent.WecomBotEvent(message_data)
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -324,7 +736,7 @@ class WecomBotClient:
|
|||||||
if is_new:
|
if is_new:
|
||||||
asyncio.create_task(self._dispatch_event(event))
|
asyncio.create_task(self._dispatch_event(event))
|
||||||
|
|
||||||
payload = self._build_stream_payload(session.stream_id, '', False)
|
payload = self._build_stream_payload(session.stream_id, '', False, feedback_id)
|
||||||
return await self._encrypt_and_reply(payload, nonce)
|
return await self._encrypt_and_reply(payload, nonce)
|
||||||
|
|
||||||
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
@@ -449,202 +861,80 @@ class WecomBotClient:
|
|||||||
|
|
||||||
msg_json = json.loads(decrypted_xml)
|
msg_json = json.loads(decrypted_xml)
|
||||||
|
|
||||||
|
event = msg_json.get('event', {})
|
||||||
|
event_type = event.get('eventtype', '')
|
||||||
|
|
||||||
|
if event_type == 'feedback_event':
|
||||||
|
return await self._handle_feedback_event(msg_json, nonce)
|
||||||
|
|
||||||
if msg_json.get('msgtype') == 'stream':
|
if msg_json.get('msgtype') == 'stream':
|
||||||
return await self._handle_post_followup_response(msg_json, nonce)
|
return await self._handle_post_followup_response(msg_json, nonce)
|
||||||
|
|
||||||
return await self._handle_post_initial_response(msg_json, nonce)
|
return await self._handle_post_initial_response(msg_json, nonce)
|
||||||
|
|
||||||
async def get_message(self, msg_json):
|
async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||||
message_data = {}
|
"""处理企业微信用户反馈事件(点赞/点踩)。
|
||||||
|
|
||||||
msg_type = msg_json.get('msgtype', '')
|
Args:
|
||||||
if msg_type:
|
msg_json: 解密后的企业微信反馈事件 JSON。
|
||||||
message_data['msgtype'] = msg_type
|
nonce: 企业微信回调参数 nonce。
|
||||||
|
|
||||||
if msg_json.get('chattype', '') == 'single':
|
Returns:
|
||||||
message_data['type'] = 'single'
|
Tuple[Response, int]: Quart Response 及状态码。
|
||||||
elif msg_json.get('chattype', '') == 'group':
|
|
||||||
message_data['type'] = 'group'
|
|
||||||
|
|
||||||
max_inline_file_size = 5 * 1024 * 1024 # avoid decoding very large payloads by default
|
Note:
|
||||||
|
企业微信协议要求:反馈事件目前仅支持回复空包。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
feedback_event = msg_json.get('event', {}).get('feedback_event', {})
|
||||||
|
feedback_id = feedback_event.get('id', '')
|
||||||
|
feedback_type = feedback_event.get('type', 0)
|
||||||
|
feedback_content = feedback_event.get('content', '')
|
||||||
|
inaccurate_reasons = feedback_event.get('inaccurate_reason_list', [])
|
||||||
|
|
||||||
async def _safe_download(url: str):
|
await self.logger.info(
|
||||||
if not url:
|
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||||
return None
|
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||||
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_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_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 = []
|
|
||||||
images = []
|
|
||||||
files = []
|
|
||||||
voices = []
|
|
||||||
videos = []
|
|
||||||
links = []
|
|
||||||
for item in items:
|
|
||||||
item_type = item.get('msgtype')
|
|
||||||
if item_type == 'text':
|
|
||||||
texts.append(item.get('text', {}).get('content', ''))
|
|
||||||
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:
|
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
||||||
message_data['content'] = ' '.join(texts) # 拼接所有 text
|
if session:
|
||||||
if images:
|
await self.logger.info(
|
||||||
message_data['images'] = images
|
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
||||||
message_data['picurl'] = images[0] # 只保留第一个 image
|
)
|
||||||
if files:
|
for handler in self._message_handlers.get('feedback', []):
|
||||||
message_data['files'] = files
|
try:
|
||||||
message_data['file'] = files[0]
|
await handler(
|
||||||
if voices:
|
feedback_id=feedback_id,
|
||||||
message_data['voices'] = voices
|
feedback_type=feedback_type,
|
||||||
message_data['voice'] = voices[0]
|
feedback_content=feedback_content,
|
||||||
if videos:
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
message_data['videos'] = videos
|
session=session,
|
||||||
message_data['video'] = videos[0]
|
)
|
||||||
if links:
|
except Exception:
|
||||||
message_data['link'] = links[0]
|
await self.logger.error(traceback.format_exc())
|
||||||
if items:
|
|
||||||
message_data['attachments'] = items
|
|
||||||
else:
|
|
||||||
message_data['raw_msg'] = msg_json
|
|
||||||
|
|
||||||
# Extract user information
|
if self._feedback_callback:
|
||||||
from_info = msg_json.get('from', {})
|
try:
|
||||||
message_data['userid'] = from_info.get('userid', '')
|
await self._feedback_callback(
|
||||||
message_data['username'] = (
|
feedback_id=feedback_id,
|
||||||
from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
|
feedback_type=feedback_type,
|
||||||
)
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(traceback.format_exc())
|
||||||
|
else:
|
||||||
|
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
|
||||||
|
|
||||||
# Extract chat/group information
|
except Exception:
|
||||||
if msg_json.get('chattype', '') == 'group':
|
await self.logger.error(traceback.format_exc())
|
||||||
message_data['chatid'] = msg_json.get('chatid', '')
|
|
||||||
# Try to get group name if available
|
|
||||||
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
|
|
||||||
|
|
||||||
message_data['msgid'] = msg_json.get('msgid', '')
|
return await self._encrypt_and_reply({}, nonce)
|
||||||
|
|
||||||
if msg_json.get('aibotid'):
|
async def get_message(self, msg_json):
|
||||||
message_data['aibotid'] = msg_json.get('aibotid', '')
|
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
|
||||||
|
|
||||||
return message_data
|
|
||||||
|
|
||||||
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
|
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
|
||||||
"""
|
"""
|
||||||
@@ -711,40 +1001,20 @@ class WecomBotClient:
|
|||||||
|
|
||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
def on_feedback(self):
|
||||||
|
def decorator(func: Callable):
|
||||||
|
if 'feedback' not in self._message_handlers:
|
||||||
|
self._message_handlers['feedback'] = []
|
||||||
|
self._message_handlers['feedback'].append(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
||||||
async with httpx.AsyncClient() as client:
|
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||||
response = await client.get(download_url)
|
if data:
|
||||||
if response.status_code != 200:
|
return _bytes_to_data_uri(data)
|
||||||
await self.logger.error(f'failed to get file: {response.text}')
|
return None
|
||||||
return None
|
|
||||||
|
|
||||||
encrypted_bytes = response.content
|
|
||||||
|
|
||||||
aes_key = base64.b64decode(encoding_aes_key + '=') # base64 补齐
|
|
||||||
iv = aes_key[:16]
|
|
||||||
|
|
||||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
|
||||||
decrypted = cipher.decrypt(encrypted_bytes)
|
|
||||||
|
|
||||||
pad_len = decrypted[-1]
|
|
||||||
decrypted = decrypted[:-pad_len]
|
|
||||||
|
|
||||||
if decrypted.startswith(b'\xff\xd8'): # JPEG
|
|
||||||
mime_type = 'image/jpeg'
|
|
||||||
elif decrypted.startswith(b'\x89PNG'): # PNG
|
|
||||||
mime_type = 'image/png'
|
|
||||||
elif decrypted.startswith((b'GIF87a', b'GIF89a')): # GIF
|
|
||||||
mime_type = 'image/gif'
|
|
||||||
elif decrypted.startswith(b'BM'): # BMP
|
|
||||||
mime_type = 'image/bmp'
|
|
||||||
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'): # TIFF
|
|
||||||
mime_type = 'image/tiff'
|
|
||||||
else:
|
|
||||||
mime_type = 'application/octet-stream'
|
|
||||||
|
|
||||||
# 转 base64
|
|
||||||
base64_str = base64.b64encode(decrypted).decode('utf-8')
|
|
||||||
return f'data:{mime_type};base64,{base64_str}'
|
|
||||||
|
|
||||||
async def run_task(self, host: str, port: int, *args, **kwargs):
|
async def run_task(self, host: str, port: int, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -133,3 +133,17 @@ class WecomBotEvent(dict):
|
|||||||
AI Bot ID
|
AI Bot ID
|
||||||
"""
|
"""
|
||||||
return self.get('aibotid', '')
|
return self.get('aibotid', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def feedback_id(self) -> str:
|
||||||
|
"""
|
||||||
|
反馈 ID,用于关联用户点赞/点踩反馈
|
||||||
|
"""
|
||||||
|
return self.get('feedback_id', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stream_id(self) -> str:
|
||||||
|
"""
|
||||||
|
流式消息 ID
|
||||||
|
"""
|
||||||
|
return self.get('stream_id', '')
|
||||||
|
|||||||
596
src/langbot/libs/wecom_ai_bot_api/ws_client.py
Normal file
596
src/langbot/libs/wecom_ai_bot_api/ws_client.py
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
"""WeChat Work AI Bot WebSocket long connection client.
|
||||||
|
|
||||||
|
Implements the WebSocket protocol for receiving messages and sending replies
|
||||||
|
via a persistent connection to wss://openws.work.weixin.qq.com, as an
|
||||||
|
alternative to the HTTP callback (webhook) mode.
|
||||||
|
|
||||||
|
Protocol reference: https://developer.work.weixin.qq.com/document/path/101463
|
||||||
|
Official Node.js SDK: https://github.com/WecomTeam/aibot-node-sdk
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
|
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message
|
||||||
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
|
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||||
|
|
||||||
|
# WebSocket frame command constants
|
||||||
|
CMD_SUBSCRIBE = 'aibot_subscribe'
|
||||||
|
CMD_HEARTBEAT = 'ping'
|
||||||
|
CMD_MSG_CALLBACK = 'aibot_msg_callback'
|
||||||
|
CMD_EVENT_CALLBACK = 'aibot_event_callback'
|
||||||
|
CMD_RESPOND_MSG = 'aibot_respond_msg'
|
||||||
|
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
||||||
|
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
|
||||||
|
CMD_SEND_MSG = 'aibot_send_msg'
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_req_id(prefix: str) -> str:
|
||||||
|
"""Generate a unique request ID in the format: {prefix}_{timestamp}_{random}."""
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
rand = secrets.token_hex(4)
|
||||||
|
return f'{prefix}_{ts}_{rand}'
|
||||||
|
|
||||||
|
|
||||||
|
class WecomBotWsClient:
|
||||||
|
"""WeChat Work AI Bot WebSocket long connection client.
|
||||||
|
|
||||||
|
Provides message receiving, streaming reply, proactive message sending,
|
||||||
|
and event callback handling over a persistent WebSocket connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bot_id: str,
|
||||||
|
secret: str,
|
||||||
|
logger: EventLogger,
|
||||||
|
encoding_aes_key: str = '',
|
||||||
|
ws_url: str = DEFAULT_WS_URL,
|
||||||
|
heartbeat_interval: float = 30.0,
|
||||||
|
max_reconnect_attempts: int = -1,
|
||||||
|
reconnect_base_delay: float = 1.0,
|
||||||
|
reconnect_max_delay: float = 30.0,
|
||||||
|
):
|
||||||
|
self.bot_id = bot_id
|
||||||
|
self.secret = secret
|
||||||
|
self.logger = logger
|
||||||
|
self.encoding_aes_key = encoding_aes_key
|
||||||
|
self.ws_url = ws_url
|
||||||
|
self.heartbeat_interval = heartbeat_interval
|
||||||
|
self.max_reconnect_attempts = max_reconnect_attempts
|
||||||
|
self.reconnect_base_delay = reconnect_base_delay
|
||||||
|
self.reconnect_max_delay = reconnect_max_delay
|
||||||
|
|
||||||
|
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
||||||
|
self._session: Optional[aiohttp.ClientSession] = None
|
||||||
|
self._running = False
|
||||||
|
self._heartbeat_task: Optional[asyncio.Task] = None
|
||||||
|
self._missed_pong_count = 0
|
||||||
|
self._max_missed_pong = 2
|
||||||
|
self._reconnect_attempts = 0
|
||||||
|
|
||||||
|
# Message handler registry (same pattern as WecomBotClient)
|
||||||
|
self._message_handlers: dict[str, list[Callable]] = {}
|
||||||
|
# Message deduplication
|
||||||
|
self._msg_id_map: dict[str, int] = {}
|
||||||
|
|
||||||
|
# Pending ACK futures: req_id -> Future[dict]
|
||||||
|
self._pending_acks: dict[str, asyncio.Future] = {}
|
||||||
|
# Per-req_id serial reply queues
|
||||||
|
self._reply_queues: dict[str, asyncio.Queue] = {}
|
||||||
|
self._reply_workers: dict[str, asyncio.Task] = {}
|
||||||
|
self._reply_ack_timeout = 5.0
|
||||||
|
|
||||||
|
# Stream ID tracking for WebSocket mode
|
||||||
|
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
||||||
|
# Dedup: skip sending when content hasn't changed
|
||||||
|
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
|
||||||
|
|
||||||
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Connect to WebSocket server with automatic reconnection.
|
||||||
|
|
||||||
|
This method blocks until disconnect() is called or max reconnect
|
||||||
|
attempts are exhausted.
|
||||||
|
"""
|
||||||
|
self._running = True
|
||||||
|
self._reconnect_attempts = 0
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self._connect_once()
|
||||||
|
except Exception:
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
await self.logger.error(f'WebSocket connection error: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Reconnect with exponential backoff
|
||||||
|
if self.max_reconnect_attempts != -1 and self._reconnect_attempts >= self.max_reconnect_attempts:
|
||||||
|
await self.logger.error(f'Max reconnect attempts reached ({self.max_reconnect_attempts}), giving up')
|
||||||
|
break
|
||||||
|
|
||||||
|
self._reconnect_attempts += 1
|
||||||
|
delay = min(
|
||||||
|
self.reconnect_base_delay * (2 ** (self._reconnect_attempts - 1)),
|
||||||
|
self.reconnect_max_delay,
|
||||||
|
)
|
||||||
|
await self.logger.info(f'Reconnecting in {delay:.1f}s (attempt {self._reconnect_attempts})...')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""Gracefully disconnect from the WebSocket server."""
|
||||||
|
self._running = False
|
||||||
|
if self._heartbeat_task and not self._heartbeat_task.done():
|
||||||
|
self._heartbeat_task.cancel()
|
||||||
|
for task in self._reply_workers.values():
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
if self._ws and not self._ws.closed:
|
||||||
|
await self._ws.close()
|
||||||
|
self._ws = None
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
self._session = None
|
||||||
|
|
||||||
|
def on_message(self, msg_type: str) -> Callable:
|
||||||
|
"""Decorator to register a message handler.
|
||||||
|
|
||||||
|
Same interface as WecomBotClient.on_message for compatibility.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_type: 'single', 'group', or specific message type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable[[wecombotevent.WecomBotEvent], Any]):
|
||||||
|
if msg_type not in self._message_handlers:
|
||||||
|
self._message_handlers[msg_type] = []
|
||||||
|
self._message_handlers[msg_type].append(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
async def reply_stream(
|
||||||
|
self,
|
||||||
|
req_id: str,
|
||||||
|
stream_id: str,
|
||||||
|
content: str,
|
||||||
|
finish: bool = False,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Send a streaming reply frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req_id: The req_id from the original message frame (must be passed through).
|
||||||
|
stream_id: The stream ID for this streaming session.
|
||||||
|
content: The content to send (supports Markdown).
|
||||||
|
finish: Whether this is the final chunk.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ACK frame dict, or None on failure.
|
||||||
|
"""
|
||||||
|
body = {
|
||||||
|
'msgtype': 'stream',
|
||||||
|
'stream': {
|
||||||
|
'id': stream_id,
|
||||||
|
'finish': finish,
|
||||||
|
'content': content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
|
async def reply_text(self, req_id: str, content: str) -> Optional[dict]:
|
||||||
|
"""Send a non-streaming text reply.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req_id: The req_id from the original message frame.
|
||||||
|
content: The text content to reply.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ACK frame dict, or None on failure.
|
||||||
|
"""
|
||||||
|
body = {
|
||||||
|
'msgtype': 'markdown',
|
||||||
|
'markdown': {
|
||||||
|
'content': content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
|
async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]:
|
||||||
|
"""Proactively send a message to a specified chat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_id: The chat ID (userid for single chat, chatid for group chat).
|
||||||
|
content: The message content.
|
||||||
|
msgtype: Message type, 'markdown' by default.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ACK frame dict, or None on failure.
|
||||||
|
"""
|
||||||
|
req_id = _generate_req_id(CMD_SEND_MSG)
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
'chatid': chat_id,
|
||||||
|
'msgtype': msgtype,
|
||||||
|
}
|
||||||
|
if msgtype == 'markdown':
|
||||||
|
body['markdown'] = {'content': content}
|
||||||
|
elif msgtype == 'text':
|
||||||
|
body['text'] = {'content': content}
|
||||||
|
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
||||||
|
|
||||||
|
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
||||||
|
"""Push a streaming chunk for a given message ID.
|
||||||
|
|
||||||
|
Compatible interface with WecomBotClient.push_stream_chunk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_id: The original message ID.
|
||||||
|
content: The cumulative content from the pipeline.
|
||||||
|
is_final: Whether this is the final chunk.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the stream session exists and chunk was sent.
|
||||||
|
"""
|
||||||
|
key = self._stream_ids.get(msg_id)
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
req_id, stream_id = key.split('|', 1)
|
||||||
|
try:
|
||||||
|
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
||||||
|
if not is_final and content == self._stream_last_content.get(msg_id):
|
||||||
|
return True
|
||||||
|
await self.reply_stream(req_id, stream_id, content, finish=is_final)
|
||||||
|
self._stream_last_content[msg_id] = content
|
||||||
|
if is_final:
|
||||||
|
self._stream_ids.pop(msg_id, None)
|
||||||
|
self._stream_last_content.pop(msg_id, None)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def set_message(self, msg_id: str, content: str):
|
||||||
|
"""Fallback: send content as a final stream chunk or direct reply.
|
||||||
|
|
||||||
|
Compatible interface with WecomBotClient.set_message.
|
||||||
|
"""
|
||||||
|
handled = await self.push_stream_chunk(msg_id, content, is_final=True)
|
||||||
|
if not handled:
|
||||||
|
await self.logger.warning(f'No active stream for msg_id={msg_id}, message dropped')
|
||||||
|
|
||||||
|
# ── Connection lifecycle ────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _connect_once(self):
|
||||||
|
"""Establish a single WebSocket connection, authenticate, and listen."""
|
||||||
|
await self.logger.info(f'Connecting to {self.ws_url}...')
|
||||||
|
|
||||||
|
self._session = aiohttp.ClientSession()
|
||||||
|
try:
|
||||||
|
self._ws = await self._session.ws_connect(self.ws_url)
|
||||||
|
self._missed_pong_count = 0
|
||||||
|
self._reconnect_attempts = 0
|
||||||
|
await self.logger.info('WebSocket connected, sending auth...')
|
||||||
|
|
||||||
|
await self._send_auth()
|
||||||
|
|
||||||
|
# Wait for auth response
|
||||||
|
auth_ok = await self._wait_for_auth()
|
||||||
|
if not auth_ok:
|
||||||
|
await self.logger.error('Authentication failed')
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.logger.info('Authenticated successfully')
|
||||||
|
|
||||||
|
# Start heartbeat
|
||||||
|
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._listen_loop()
|
||||||
|
finally:
|
||||||
|
if self._heartbeat_task and not self._heartbeat_task.done():
|
||||||
|
self._heartbeat_task.cancel()
|
||||||
|
self._clear_pending_acks('Connection closed')
|
||||||
|
finally:
|
||||||
|
if self._ws and not self._ws.closed:
|
||||||
|
await self._ws.close()
|
||||||
|
self._ws = None
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
self._session = None
|
||||||
|
|
||||||
|
async def _send_auth(self):
|
||||||
|
"""Send the authentication frame."""
|
||||||
|
frame = {
|
||||||
|
'cmd': CMD_SUBSCRIBE,
|
||||||
|
'headers': {'req_id': _generate_req_id(CMD_SUBSCRIBE)},
|
||||||
|
'body': {
|
||||||
|
'bot_id': self.bot_id,
|
||||||
|
'secret': self.secret,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await self._send_frame(frame)
|
||||||
|
|
||||||
|
async def _wait_for_auth(self) -> bool:
|
||||||
|
"""Wait for and validate the authentication response."""
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(self._ws.receive(), timeout=10.0)
|
||||||
|
if msg.type in (aiohttp.WSMsgType.TEXT,):
|
||||||
|
frame = json.loads(msg.data)
|
||||||
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
if req_id.startswith(CMD_SUBSCRIBE) and frame.get('errcode') == 0:
|
||||||
|
return True
|
||||||
|
await self.logger.error(f'Auth response: errcode={frame.get("errcode")}, errmsg={frame.get("errmsg")}')
|
||||||
|
return False
|
||||||
|
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
|
||||||
|
await self.logger.error(f'WebSocket closed during auth: {msg.type}')
|
||||||
|
return False
|
||||||
|
await self.logger.error(f'Unexpected message type during auth: {msg.type}')
|
||||||
|
return False
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await self.logger.error('Auth response timeout')
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _heartbeat_loop(self):
|
||||||
|
"""Periodically send heartbeat pings."""
|
||||||
|
try:
|
||||||
|
while self._running and self._ws and not self._ws.closed:
|
||||||
|
await asyncio.sleep(self.heartbeat_interval)
|
||||||
|
if not self._running or not self._ws or self._ws.closed:
|
||||||
|
break
|
||||||
|
|
||||||
|
if self._missed_pong_count >= self._max_missed_pong:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'No heartbeat ack for {self._missed_pong_count} consecutive pings, connection considered dead'
|
||||||
|
)
|
||||||
|
await self._ws.close()
|
||||||
|
break
|
||||||
|
|
||||||
|
self._missed_pong_count += 1
|
||||||
|
frame = {
|
||||||
|
'cmd': CMD_HEARTBEAT,
|
||||||
|
'headers': {'req_id': _generate_req_id(CMD_HEARTBEAT)},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await self._send_frame(frame)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _listen_loop(self):
|
||||||
|
"""Listen for incoming WebSocket frames and dispatch them."""
|
||||||
|
async for msg in self._ws:
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||||
|
try:
|
||||||
|
frame = json.loads(msg.data)
|
||||||
|
await self._handle_frame(frame)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self.logger.error(f'Failed to parse WebSocket message: {str(msg.data)[:200]}')
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling frame: {traceback.format_exc()}')
|
||||||
|
elif msg.type == aiohttp.WSMsgType.BINARY:
|
||||||
|
try:
|
||||||
|
frame = json.loads(msg.data)
|
||||||
|
await self._handle_frame(frame)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling binary frame: {traceback.format_exc()}')
|
||||||
|
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
|
||||||
|
await self.logger.warning(f'WebSocket connection closed: {msg.type}')
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── Frame handling ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle_frame(self, frame: dict):
|
||||||
|
"""Route an incoming frame to the appropriate handler."""
|
||||||
|
cmd = frame.get('cmd', '')
|
||||||
|
|
||||||
|
# Message push
|
||||||
|
if cmd == CMD_MSG_CALLBACK:
|
||||||
|
asyncio.create_task(self._handle_message_callback(frame))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Event push
|
||||||
|
if cmd == CMD_EVENT_CALLBACK:
|
||||||
|
asyncio.create_task(self._handle_event_callback(frame))
|
||||||
|
return
|
||||||
|
|
||||||
|
# No cmd → response/ACK frame, dispatch by req_id prefix
|
||||||
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
|
# Check pending ACKs first
|
||||||
|
if req_id in self._pending_acks:
|
||||||
|
future = self._pending_acks.pop(req_id)
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(frame)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Heartbeat response
|
||||||
|
if req_id.startswith(CMD_HEARTBEAT):
|
||||||
|
if frame.get('errcode') == 0:
|
||||||
|
self._missed_pong_count = 0
|
||||||
|
return
|
||||||
|
|
||||||
|
# Unknown frame
|
||||||
|
await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}')
|
||||||
|
|
||||||
|
async def _handle_message_callback(self, frame: dict):
|
||||||
|
"""Handle an incoming message callback frame."""
|
||||||
|
try:
|
||||||
|
body = frame.get('body', {})
|
||||||
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
|
# Parse message using shared logic
|
||||||
|
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
|
||||||
|
if not message_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate stream_id for this message and store the mapping
|
||||||
|
stream_id = _generate_req_id('stream')
|
||||||
|
msg_id = message_data.get('msgid', '')
|
||||||
|
if msg_id:
|
||||||
|
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
|
||||||
|
message_data['stream_id'] = stream_id
|
||||||
|
message_data['req_id'] = req_id
|
||||||
|
|
||||||
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
await self._dispatch_event(event)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def _handle_event_callback(self, frame: dict):
|
||||||
|
"""Handle an incoming event callback frame (enter_chat, template_card_event, etc.)."""
|
||||||
|
try:
|
||||||
|
body = frame.get('body', {})
|
||||||
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
|
event_info = body.get('event', {})
|
||||||
|
event_type = event_info.get('eventtype', '')
|
||||||
|
|
||||||
|
message_data = {
|
||||||
|
'msgtype': 'event',
|
||||||
|
'type': body.get('chattype', 'single'),
|
||||||
|
'event': event_info,
|
||||||
|
'eventtype': event_type,
|
||||||
|
'msgid': body.get('msgid', ''),
|
||||||
|
'aibotid': body.get('aibotid', ''),
|
||||||
|
'req_id': req_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
from_info = body.get('from', {})
|
||||||
|
message_data['userid'] = from_info.get('userid', '')
|
||||||
|
message_data['username'] = from_info.get('alias', '') or from_info.get('userid', '')
|
||||||
|
|
||||||
|
if body.get('chatid'):
|
||||||
|
message_data['chatid'] = body.get('chatid', '')
|
||||||
|
|
||||||
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
|
||||||
|
# Dispatch to event-specific handlers
|
||||||
|
if event_type in self._message_handlers:
|
||||||
|
for handler in self._message_handlers[event_type]:
|
||||||
|
await handler(event)
|
||||||
|
|
||||||
|
# Also dispatch to generic 'event' handlers
|
||||||
|
if 'event' in self._message_handlers:
|
||||||
|
for handler in self._message_handlers['event']:
|
||||||
|
await handler(event)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in event callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):
|
||||||
|
"""Dispatch a message event to registered handlers with deduplication."""
|
||||||
|
try:
|
||||||
|
message_id = event.message_id
|
||||||
|
if message_id in self._msg_id_map:
|
||||||
|
self._msg_id_map[message_id] += 1
|
||||||
|
return
|
||||||
|
self._msg_id_map[message_id] = 1
|
||||||
|
|
||||||
|
msg_type = event.type
|
||||||
|
if msg_type in self._message_handlers:
|
||||||
|
for handler in self._message_handlers[msg_type]:
|
||||||
|
await handler(event)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error dispatching event: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
# ── Reply sending with serial queue ─────────────────────────────
|
||||||
|
|
||||||
|
async def _send_reply(
|
||||||
|
self,
|
||||||
|
req_id: str,
|
||||||
|
body: dict,
|
||||||
|
cmd: str = CMD_RESPOND_MSG,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Send a reply frame and wait for ACK.
|
||||||
|
|
||||||
|
Replies with the same req_id are serialized to maintain ordering.
|
||||||
|
"""
|
||||||
|
if not self._ws or self._ws.closed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
frame = {
|
||||||
|
'cmd': cmd,
|
||||||
|
'headers': {'req_id': req_id},
|
||||||
|
'body': body,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure serial delivery per req_id
|
||||||
|
if req_id not in self._reply_queues:
|
||||||
|
self._reply_queues[req_id] = asyncio.Queue()
|
||||||
|
self._reply_workers[req_id] = asyncio.create_task(self._reply_queue_worker(req_id))
|
||||||
|
|
||||||
|
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||||
|
await self._reply_queues[req_id].put((frame, future))
|
||||||
|
return await future
|
||||||
|
|
||||||
|
async def _reply_queue_worker(self, req_id: str):
|
||||||
|
"""Process reply queue items serially for a given req_id."""
|
||||||
|
queue = self._reply_queues[req_id]
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
frame, future = await asyncio.wait_for(queue.get(), timeout=60.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Queue idle, clean up worker
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
ack = await self._send_and_wait_ack(frame)
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(ack)
|
||||||
|
except Exception as e:
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(e)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._reply_queues.pop(req_id, None)
|
||||||
|
self._reply_workers.pop(req_id, None)
|
||||||
|
|
||||||
|
async def _send_and_wait_ack(self, frame: dict) -> Optional[dict]:
|
||||||
|
"""Send a frame and wait for the corresponding ACK."""
|
||||||
|
req_id = frame['headers']['req_id']
|
||||||
|
ack_future: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||||
|
self._pending_acks[req_id] = ack_future
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._send_frame(frame)
|
||||||
|
result = await asyncio.wait_for(ack_future, timeout=self._reply_ack_timeout)
|
||||||
|
if result.get('errcode', 0) != 0:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'Reply ACK error: errcode={result.get("errcode")}, errmsg={result.get("errmsg")}'
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self._pending_acks.pop(req_id, None)
|
||||||
|
await self.logger.warning(f'Reply ACK timeout ({self._reply_ack_timeout}s) for req_id={req_id}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _send_frame(self, frame: dict):
|
||||||
|
"""Send a JSON frame over the WebSocket connection."""
|
||||||
|
if self._ws and not self._ws.closed:
|
||||||
|
await self._ws.send_str(json.dumps(frame, ensure_ascii=False))
|
||||||
|
|
||||||
|
def _clear_pending_acks(self, reason: str):
|
||||||
|
"""Reject all pending ACK futures on disconnection."""
|
||||||
|
for req_id, future in self._pending_acks.items():
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(ConnectionError(reason))
|
||||||
|
self._pending_acks.clear()
|
||||||
@@ -4,6 +4,7 @@ import base64
|
|||||||
import binascii
|
import binascii
|
||||||
import httpx
|
import httpx
|
||||||
import traceback
|
import traceback
|
||||||
|
from urllib.parse import quote
|
||||||
from quart import Quart
|
from quart import Quart
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Callable, Dict, Any
|
from typing import Callable, Dict, Any
|
||||||
@@ -67,6 +68,31 @@ class WecomClient:
|
|||||||
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
await self.logger.error(f'获取accesstoken失败:{response.json()}')
|
||||||
raise Exception(f'未获取access token: {data}')
|
raise Exception(f'未获取access token: {data}')
|
||||||
|
|
||||||
|
async def get_user_info(self, userid: str) -> dict:
|
||||||
|
"""
|
||||||
|
Get user information by user ID using the application secret.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userid: The user ID to look up.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: User information including 'name' field.
|
||||||
|
"""
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
|
url = self.base_url + '/user/get?access_token=' + self.access_token + '&userid=' + quote(userid)
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(url)
|
||||||
|
data = response.json()
|
||||||
|
if data.get('errcode') == 40014 or data.get('errcode') == 42001:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.get_user_info(userid)
|
||||||
|
if data.get('errcode', 0) != 0:
|
||||||
|
await self.logger.error(f'获取用户信息失败:{data}')
|
||||||
|
return {}
|
||||||
|
return data
|
||||||
|
|
||||||
async def get_users(self):
|
async def get_users(self):
|
||||||
if not self.check_access_token_for_contacts():
|
if not self.check_access_token_for_contacts():
|
||||||
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
self.access_token_for_contacts = await self.get_access_token(self.secret_for_contacts)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from typing import Callable
|
|||||||
from .wecomcsevent import WecomCSEvent
|
from .wecomcsevent import WecomCSEvent
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
class WecomCSClient:
|
class WecomCSClient:
|
||||||
@@ -34,6 +35,10 @@ class WecomCSClient:
|
|||||||
self.unified_mode = unified_mode
|
self.unified_mode = unified_mode
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
|
||||||
|
# Customer info cache: {external_userid: (info_dict, timestamp)}
|
||||||
|
self._customer_cache: dict[str, tuple[dict, float]] = {}
|
||||||
|
self._cache_ttl = 60 # Cache TTL in seconds (1 minute)
|
||||||
|
|
||||||
# 只有在非统一模式下才注册独立路由
|
# 只有在非统一模式下才注册独立路由
|
||||||
if not self.unified_mode:
|
if not self.unified_mode:
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
@@ -378,3 +383,53 @@ class WecomCSClient:
|
|||||||
async def get_media_id(self, image: platform_message.Image):
|
async def get_media_id(self, image: platform_message.Image):
|
||||||
media_id = await self.upload_to_work(image=image)
|
media_id = await self.upload_to_work(image=image)
|
||||||
return media_id
|
return media_id
|
||||||
|
|
||||||
|
async def get_customer_info(self, external_userid: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
Get customer information by external_userid with caching.
|
||||||
|
|
||||||
|
Uses a 1-minute cache to avoid repeated API calls for the same user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
external_userid: The external user ID of the customer.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Customer info dict with 'nickname', 'avatar', etc., or None if not found.
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
current_time = time.time()
|
||||||
|
if external_userid in self._customer_cache:
|
||||||
|
cached_info, cached_time = self._customer_cache[external_userid]
|
||||||
|
if current_time - cached_time < self._cache_ttl:
|
||||||
|
return cached_info
|
||||||
|
|
||||||
|
# Cache miss or expired, fetch from API
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
|
url = f'{self.base_url}/kf/customer/batchget?access_token={self.access_token}'
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'external_userid_list': [external_userid],
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get('errcode') in [40014, 42001]:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.get_customer_info(external_userid)
|
||||||
|
|
||||||
|
if data.get('errcode', 0) != 0:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.warning(f'Failed to get customer info: {data}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
customer_list = data.get('customer_list', [])
|
||||||
|
if customer_list:
|
||||||
|
customer_info = customer_list[0]
|
||||||
|
# Store in cache
|
||||||
|
self._customer_cache[external_userid] = (customer_info, current_time)
|
||||||
|
return customer_info
|
||||||
|
return None
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ from .. import group
|
|||||||
@group.group_class('files', '/api/v1/files')
|
@group.group_class('files', '/api/v1/files')
|
||||||
class FilesRouterGroup(group.RouterGroup):
|
class FilesRouterGroup(group.RouterGroup):
|
||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route('/image/<image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
@self.route('/image/<path:image_key>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
async def _(image_key: str) -> quart.Response:
|
async def _(image_key: str) -> quart.Response:
|
||||||
if '/' in image_key or '\\' in image_key:
|
if '..' in image_key or '\\' in image_key:
|
||||||
return quart.Response(status=404)
|
return quart.Response(status=404)
|
||||||
|
|
||||||
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
|
if not await self.ap.storage_mgr.storage_provider.exists(image_key):
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import quart
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
|
from ... import group
|
||||||
|
from ......core import taskmgr
|
||||||
|
from ......entity.persistence import metadata as persistence_metadata
|
||||||
|
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||||
|
|
||||||
|
LANGRAG_PLUGIN_AUTHOR = 'langbot-team'
|
||||||
|
LANGRAG_PLUGIN_NAME = 'LangRAG'
|
||||||
|
LANGRAG_PLUGIN_ID = f'{LANGRAG_PLUGIN_AUTHOR}/{LANGRAG_PLUGIN_NAME}'
|
||||||
|
DEFAULT_SPACE_URL = 'https://space.langbot.app'
|
||||||
|
|
||||||
|
# Old Retriever plugin_name -> New Connector plugin_name
|
||||||
|
EXTERNAL_PLUGIN_NAME_MAPPING = {
|
||||||
|
'DifyDatasetsRetriever': 'DifyDatasetsConnector',
|
||||||
|
'RAGFlowRetriever': 'RAGFlowConnector',
|
||||||
|
'FastGPTRetriever': 'FastGPTConnector',
|
||||||
|
}
|
||||||
|
|
||||||
|
# Per-plugin: which old retriever_config fields belong to creation_settings.
|
||||||
|
# Remaining fields go to retrieval_settings.
|
||||||
|
# None means ALL fields go to creation_settings (no retrieval_schema).
|
||||||
|
EXTERNAL_PLUGIN_CREATION_FIELDS: dict[str, set[str] | None] = {
|
||||||
|
'langbot-team/DifyDatasetsConnector': {'api_base_url', 'dify_apikey', 'dataset_id'},
|
||||||
|
'langbot-team/RAGFlowConnector': {'api_base_url', 'api_key', 'dataset_ids'},
|
||||||
|
'langbot-team/FastGPTConnector': None, # all fields -> creation_settings
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@group.group_class('knowledge/migration', '/api/v1/knowledge/migration')
|
||||||
|
class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||||
|
async def _get_migration_flag(self) -> bool:
|
||||||
|
"""Check if rag_plugin_migration_needed flag is set."""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_metadata.Metadata).where(
|
||||||
|
persistence_metadata.Metadata.key == 'rag_plugin_migration_needed'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row = result.first()
|
||||||
|
return row is not None and row.value == 'true'
|
||||||
|
|
||||||
|
async def _set_migration_flag(self, value: str):
|
||||||
|
"""Set rag_plugin_migration_needed flag."""
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_metadata.Metadata)
|
||||||
|
.where(persistence_metadata.Metadata.key == 'rag_plugin_migration_needed')
|
||||||
|
.values(value=value)
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _table_exists(self, table_name: str) -> bool:
|
||||||
|
"""Check if a table exists."""
|
||||||
|
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
|
||||||
|
).bindparams(table_name=table_name)
|
||||||
|
)
|
||||||
|
return result.scalar()
|
||||||
|
else:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
|
||||||
|
table_name=table_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.first() is not None
|
||||||
|
|
||||||
|
async def _install_plugin_from_marketplace(
|
||||||
|
self, plugin_id: str, task_context: taskmgr.TaskContext, space_url: str
|
||||||
|
) -> None:
|
||||||
|
"""Install a single plugin from the marketplace."""
|
||||||
|
p_author, p_name = plugin_id.split('/', 1)
|
||||||
|
self.ap.logger.info(f'RAG migration: installing plugin {plugin_id} from marketplace...')
|
||||||
|
task_context.trace(f'Installing plugin {plugin_id} from marketplace...')
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(trust_env=True, timeout=15) as client:
|
||||||
|
resp = await client.get(f'{space_url}/api/v1/marketplace/plugins/{p_author}/{p_name}')
|
||||||
|
resp.raise_for_status()
|
||||||
|
p_data = resp.json().get('data', {}).get('plugin', {})
|
||||||
|
p_version = p_data.get('latest_version')
|
||||||
|
if not p_version:
|
||||||
|
raise Exception(f'Could not determine latest version for {plugin_id}')
|
||||||
|
|
||||||
|
await self.ap.plugin_connector.install_plugin(
|
||||||
|
PluginInstallSource.MARKETPLACE,
|
||||||
|
{
|
||||||
|
'plugin_author': p_author,
|
||||||
|
'plugin_name': p_name,
|
||||||
|
'plugin_version': p_version,
|
||||||
|
},
|
||||||
|
task_context=task_context,
|
||||||
|
)
|
||||||
|
self.ap.logger.info(f'RAG migration: plugin {plugin_id} install request sent.')
|
||||||
|
|
||||||
|
async def _execute_rag_migration(self, task_context: taskmgr.TaskContext, install_plugin: bool = True):
|
||||||
|
"""Execute RAG migration: install required plugins and restore backup data."""
|
||||||
|
warnings = []
|
||||||
|
|
||||||
|
# Collect all plugins we need: LangRAG (always) + connector plugins (from external KBs)
|
||||||
|
needed_plugins: dict[str, str] = {
|
||||||
|
LANGRAG_PLUGIN_ID: LANGRAG_PLUGIN_NAME,
|
||||||
|
}
|
||||||
|
|
||||||
|
has_external = await self._table_exists('external_knowledge_bases')
|
||||||
|
if has_external:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('SELECT DISTINCT plugin_author, plugin_name FROM external_knowledge_bases;')
|
||||||
|
)
|
||||||
|
for row in result.fetchall():
|
||||||
|
plugin_author = row[0] or ''
|
||||||
|
plugin_name = row[1] or ''
|
||||||
|
mapped_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name)
|
||||||
|
plugin_id = f'{plugin_author}/{mapped_name}'
|
||||||
|
if plugin_id not in needed_plugins:
|
||||||
|
needed_plugins[plugin_id] = mapped_name
|
||||||
|
|
||||||
|
self.ap.logger.info(f'RAG migration: plugins needed: {list(needed_plugins.keys())}')
|
||||||
|
|
||||||
|
if install_plugin:
|
||||||
|
# Step 1: Install all required plugins from marketplace
|
||||||
|
task_context.trace('Installing required plugins...', action='install-plugin')
|
||||||
|
space_url = self.ap.instance_config.data.get('space', {}).get('url', DEFAULT_SPACE_URL).rstrip('/')
|
||||||
|
|
||||||
|
for plugin_id in needed_plugins:
|
||||||
|
try:
|
||||||
|
await self._install_plugin_from_marketplace(plugin_id, task_context, space_url)
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.warning(f'RAG migration: plugin {plugin_id} install returned: {e}')
|
||||||
|
task_context.trace(f'Plugin install note ({plugin_id}): {e}')
|
||||||
|
|
||||||
|
# Step 2: Wait for all plugins to become available as knowledge engines
|
||||||
|
task_context.trace(
|
||||||
|
f'Waiting for plugins to become available: {list(needed_plugins.keys())}...',
|
||||||
|
action='wait-plugin',
|
||||||
|
)
|
||||||
|
max_retries = 30
|
||||||
|
engine_id_set: set[str] = set()
|
||||||
|
for i in range(max_retries):
|
||||||
|
try:
|
||||||
|
engines = await self.ap.plugin_connector.list_knowledge_engines()
|
||||||
|
engine_id_set = {e.get('plugin_id') for e in engines}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if all(pid in engine_id_set for pid in needed_plugins):
|
||||||
|
self.ap.logger.info(f'RAG migration: all plugins ready: {engine_id_set}')
|
||||||
|
task_context.trace('All required plugins are ready.')
|
||||||
|
break
|
||||||
|
if i == max_retries - 1:
|
||||||
|
still_missing = [pid for pid in needed_plugins if pid not in engine_id_set]
|
||||||
|
warning = f'Plugin(s) {still_missing} did not become available after {max_retries} retries'
|
||||||
|
self.ap.logger.warning(f'RAG migration: {warning}')
|
||||||
|
warnings.append(warning)
|
||||||
|
task_context.trace(warning)
|
||||||
|
await asyncio.sleep(2)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
engines = await self.ap.plugin_connector.list_knowledge_engines()
|
||||||
|
engine_id_set = {e.get('plugin_id') for e in engines}
|
||||||
|
except Exception:
|
||||||
|
engine_id_set = set()
|
||||||
|
|
||||||
|
# Step 3: Restore internal knowledge bases from backup
|
||||||
|
task_context.trace('Restoring internal knowledge bases...', action='restore-internal')
|
||||||
|
if await self._table_exists('knowledge_bases_backup'):
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('SELECT * FROM knowledge_bases_backup;')
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
columns = result.keys()
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
row_dict = dict(zip(columns, row))
|
||||||
|
kb_uuid = row_dict.get('uuid')
|
||||||
|
name = row_dict.get('name', 'Untitled')
|
||||||
|
description = row_dict.get('description', '')
|
||||||
|
emoji = row_dict.get('emoji', '\U0001f4da')
|
||||||
|
embedding_model_uuid = row_dict.get('embedding_model_uuid', '')
|
||||||
|
top_k = row_dict.get('top_k', 5)
|
||||||
|
created_at = row_dict.get('created_at')
|
||||||
|
updated_at = row_dict.get('updated_at')
|
||||||
|
|
||||||
|
creation_settings = json.dumps({'embedding_model_uuid': embedding_model_uuid})
|
||||||
|
retrieval_settings = json.dumps({'top_k': top_k})
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'INSERT INTO knowledge_bases '
|
||||||
|
'(uuid, name, description, emoji, created_at, updated_at, '
|
||||||
|
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
|
||||||
|
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
|
||||||
|
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
|
||||||
|
).bindparams(
|
||||||
|
uuid=kb_uuid,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
emoji=emoji,
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=updated_at,
|
||||||
|
plugin_id=LANGRAG_PLUGIN_ID,
|
||||||
|
collection_id=kb_uuid,
|
||||||
|
creation_settings=creation_settings,
|
||||||
|
retrieval_settings=retrieval_settings,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = {'embedding_model_uuid': embedding_model_uuid}
|
||||||
|
await self.ap.plugin_connector.rag_on_kb_create(LANGRAG_PLUGIN_ID, kb_uuid, config)
|
||||||
|
task_context.trace(f'Restored internal KB: {name} ({kb_uuid})')
|
||||||
|
except Exception as e:
|
||||||
|
warning = f'Failed to notify plugin for KB {name} ({kb_uuid}): {e}'
|
||||||
|
warnings.append(warning)
|
||||||
|
task_context.trace(warning)
|
||||||
|
|
||||||
|
await self.ap.rag_mgr.load_knowledge_bases_from_db()
|
||||||
|
|
||||||
|
# Step 4: Restore external knowledge bases
|
||||||
|
task_context.trace('Restoring external knowledge bases...', action='restore-external')
|
||||||
|
if has_external:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('SELECT * FROM external_knowledge_bases;')
|
||||||
|
)
|
||||||
|
rows = result.fetchall()
|
||||||
|
columns = result.keys()
|
||||||
|
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'RAG migration: {len(rows)} external KB(s) to restore. Available engines: {engine_id_set}'
|
||||||
|
)
|
||||||
|
task_context.trace(f'Found {len(rows)} external KB(s). Available engines: {engine_id_set}')
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
row_dict = dict(zip(columns, row))
|
||||||
|
kb_uuid = row_dict.get('uuid')
|
||||||
|
name = row_dict.get('name', 'Untitled')
|
||||||
|
description = row_dict.get('description', '')
|
||||||
|
emoji = row_dict.get('emoji', '\U0001f517')
|
||||||
|
plugin_author = row_dict.get('plugin_author', '')
|
||||||
|
plugin_name = row_dict.get('plugin_name', '')
|
||||||
|
retriever_config = row_dict.get('retriever_config', {})
|
||||||
|
created_at = row_dict.get('created_at')
|
||||||
|
|
||||||
|
mapped_plugin_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name)
|
||||||
|
external_plugin_id = f'{plugin_author}/{mapped_plugin_name}'
|
||||||
|
|
||||||
|
self.ap.logger.info(
|
||||||
|
f'RAG migration: processing external KB "{name}" ({kb_uuid}), '
|
||||||
|
f'plugin: {plugin_author}/{plugin_name} -> {external_plugin_id}'
|
||||||
|
)
|
||||||
|
|
||||||
|
if isinstance(retriever_config, str):
|
||||||
|
try:
|
||||||
|
retriever_config = json.loads(retriever_config)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
retriever_config = {}
|
||||||
|
|
||||||
|
creation_fields = EXTERNAL_PLUGIN_CREATION_FIELDS.get(external_plugin_id)
|
||||||
|
if creation_fields is None:
|
||||||
|
creation_settings_dict = retriever_config
|
||||||
|
retrieval_settings_dict = {}
|
||||||
|
else:
|
||||||
|
creation_settings_dict = {k: v for k, v in retriever_config.items() if k in creation_fields}
|
||||||
|
retrieval_settings_dict = {k: v for k, v in retriever_config.items() if k not in creation_fields}
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'INSERT INTO knowledge_bases '
|
||||||
|
'(uuid, name, description, emoji, created_at, updated_at, '
|
||||||
|
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
|
||||||
|
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
|
||||||
|
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
|
||||||
|
).bindparams(
|
||||||
|
uuid=kb_uuid,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
emoji=emoji,
|
||||||
|
created_at=created_at,
|
||||||
|
updated_at=created_at,
|
||||||
|
plugin_id=external_plugin_id,
|
||||||
|
collection_id=kb_uuid,
|
||||||
|
creation_settings=json.dumps(creation_settings_dict),
|
||||||
|
retrieval_settings=json.dumps(retrieval_settings_dict),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if external_plugin_id not in engine_id_set:
|
||||||
|
warning = (
|
||||||
|
f'External KB "{name}" ({kb_uuid}) record saved, but plugin {external_plugin_id} '
|
||||||
|
f'is not installed yet. Install the connector plugin to use it.'
|
||||||
|
)
|
||||||
|
warnings.append(warning)
|
||||||
|
task_context.trace(warning)
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
await self.ap.plugin_connector.rag_on_kb_create(
|
||||||
|
external_plugin_id, kb_uuid, creation_settings_dict
|
||||||
|
)
|
||||||
|
task_context.trace(f'Restored external KB: {name} ({kb_uuid})')
|
||||||
|
except Exception as e:
|
||||||
|
warning = f'Failed to notify plugin for external KB {name} ({kb_uuid}): {e}'
|
||||||
|
warnings.append(warning)
|
||||||
|
task_context.trace(warning)
|
||||||
|
|
||||||
|
await self.ap.rag_mgr.load_knowledge_bases_from_db()
|
||||||
|
|
||||||
|
# Step 5: Clear migration flag
|
||||||
|
await self._set_migration_flag('false')
|
||||||
|
task_context.trace('RAG migration completed.', action='done')
|
||||||
|
|
||||||
|
if warnings:
|
||||||
|
task_context.trace(f'Completed with {len(warnings)} warning(s).')
|
||||||
|
|
||||||
|
async def initialize(self) -> None:
|
||||||
|
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
needed = await self._get_migration_flag()
|
||||||
|
|
||||||
|
internal_kb_count = 0
|
||||||
|
external_kb_count = 0
|
||||||
|
|
||||||
|
if needed:
|
||||||
|
if await self._table_exists('knowledge_bases_backup'):
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases_backup;')
|
||||||
|
)
|
||||||
|
internal_kb_count = result.scalar() or 0
|
||||||
|
|
||||||
|
if await self._table_exists('external_knowledge_bases'):
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')
|
||||||
|
)
|
||||||
|
external_kb_count = result.scalar() or 0
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'needed': needed,
|
||||||
|
'internal_kb_count': internal_kb_count,
|
||||||
|
'external_kb_count': external_kb_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
@self.route('/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
needed = await self._get_migration_flag()
|
||||||
|
if not needed:
|
||||||
|
return self.http_status(400, -1, 'RAG migration is not needed')
|
||||||
|
|
||||||
|
data = await quart.request.get_json(silent=True) or {}
|
||||||
|
install_plugin = data.get('install_plugin', True)
|
||||||
|
|
||||||
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
wrapper = self.ap.task_mgr.create_user_task(
|
||||||
|
self._execute_rag_migration(task_context=ctx, install_plugin=install_plugin),
|
||||||
|
kind='rag-migration',
|
||||||
|
name='rag-migration-execute',
|
||||||
|
label='Migrating knowledge bases to plugin architecture',
|
||||||
|
context=ctx,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(data={'task_id': wrapper.id})
|
||||||
|
|
||||||
|
@self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
needed = await self._get_migration_flag()
|
||||||
|
if not needed:
|
||||||
|
return self.http_status(400, -1, 'RAG migration is not needed')
|
||||||
|
|
||||||
|
await self._set_migration_flag('false')
|
||||||
|
return self.success()
|
||||||
@@ -456,6 +456,31 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
'platform',
|
'platform',
|
||||||
'user_id',
|
'user_id',
|
||||||
]
|
]
|
||||||
|
elif export_type == 'feedback':
|
||||||
|
data = await self.ap.monitoring_service.export_feedback(
|
||||||
|
bot_ids=bot_ids if bot_ids else None,
|
||||||
|
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
headers = [
|
||||||
|
'id',
|
||||||
|
'timestamp',
|
||||||
|
'feedback_id',
|
||||||
|
'feedback_type',
|
||||||
|
'feedback_content',
|
||||||
|
'inaccurate_reasons',
|
||||||
|
'bot_id',
|
||||||
|
'bot_name',
|
||||||
|
'pipeline_id',
|
||||||
|
'pipeline_name',
|
||||||
|
'session_id',
|
||||||
|
'message_id',
|
||||||
|
'stream_id',
|
||||||
|
'user_id',
|
||||||
|
'platform',
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
return self.error(message=f'Invalid export type: {export_type}', code=400)
|
return self.error(message=f'Invalid export type: {export_type}', code=400)
|
||||||
|
|
||||||
@@ -486,3 +511,63 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return response, 200
|
return response, 200
|
||||||
|
|
||||||
|
@self.route('/feedback/stats', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def get_feedback_stats() -> str:
|
||||||
|
"""Get feedback statistics"""
|
||||||
|
# Parse query parameters
|
||||||
|
bot_ids = quart.request.args.getlist('botId')
|
||||||
|
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||||
|
start_time_str = quart.request.args.get('startTime')
|
||||||
|
end_time_str = quart.request.args.get('endTime')
|
||||||
|
|
||||||
|
# Parse datetime
|
||||||
|
start_time = parse_iso_datetime(start_time_str)
|
||||||
|
end_time = parse_iso_datetime(end_time_str)
|
||||||
|
|
||||||
|
stats = await self.ap.monitoring_service.get_feedback_stats(
|
||||||
|
bot_ids=bot_ids if bot_ids else None,
|
||||||
|
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(data=stats)
|
||||||
|
|
||||||
|
@self.route('/feedback', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def get_feedback() -> str:
|
||||||
|
"""Get feedback list"""
|
||||||
|
# Parse query parameters
|
||||||
|
bot_ids = quart.request.args.getlist('botId')
|
||||||
|
pipeline_ids = quart.request.args.getlist('pipelineId')
|
||||||
|
feedback_type_str = quart.request.args.get('feedbackType')
|
||||||
|
start_time_str = quart.request.args.get('startTime')
|
||||||
|
end_time_str = quart.request.args.get('endTime')
|
||||||
|
limit = int(quart.request.args.get('limit', 100))
|
||||||
|
offset = int(quart.request.args.get('offset', 0))
|
||||||
|
|
||||||
|
# Parse datetime
|
||||||
|
start_time = parse_iso_datetime(start_time_str)
|
||||||
|
end_time = parse_iso_datetime(end_time_str)
|
||||||
|
|
||||||
|
# Parse feedback type
|
||||||
|
feedback_type = int(feedback_type_str) if feedback_type_str else None
|
||||||
|
|
||||||
|
feedback_list, total = await self.ap.monitoring_service.get_feedback_list(
|
||||||
|
bot_ids=bot_ids if bot_ids else None,
|
||||||
|
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
return self.success(
|
||||||
|
data={
|
||||||
|
'feedback': feedback_list,
|
||||||
|
'total': total,
|
||||||
|
'limit': limit,
|
||||||
|
'offset': offset,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
@@ -265,6 +265,8 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
return self.http_status(400, -1, 'Missing asset_url parameter')
|
return self.http_status(400, -1, 'Missing asset_url parameter')
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
|
||||||
|
ctx.metadata['install_source'] = 'github'
|
||||||
install_info = {
|
install_info = {
|
||||||
'asset_url': asset_url,
|
'asset_url': asset_url,
|
||||||
'owner': owner,
|
'owner': owner,
|
||||||
@@ -295,12 +297,17 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
data = await quart.request.json
|
data = await quart.request.json
|
||||||
|
|
||||||
|
plugin_author = data.get('plugin_author', '')
|
||||||
|
plugin_name = data.get('plugin_name', '')
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
ctx.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||||
|
ctx.metadata['install_source'] = 'marketplace'
|
||||||
wrapper = self.ap.task_mgr.create_user_task(
|
wrapper = self.ap.task_mgr.create_user_task(
|
||||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
|
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
|
||||||
kind='plugin-operation',
|
kind='plugin-operation',
|
||||||
name='plugin-install-marketplace',
|
name='plugin-install-marketplace',
|
||||||
label=f'Installing plugin from marketplace ...{data}',
|
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
|
||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -323,11 +330,13 @@ class PluginsRouterGroup(group.RouterGroup):
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx = taskmgr.TaskContext.new()
|
ctx = taskmgr.TaskContext.new()
|
||||||
|
ctx.metadata['plugin_name'] = file.filename or 'local plugin'
|
||||||
|
ctx.metadata['install_source'] = 'local'
|
||||||
wrapper = self.ap.task_mgr.create_user_task(
|
wrapper = self.ap.task_mgr.create_user_task(
|
||||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
|
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
|
||||||
kind='plugin-operation',
|
kind='plugin-operation',
|
||||||
name='plugin-install-local',
|
name='plugin-install-local',
|
||||||
label=f'Installing plugin from local ...{file.filename}',
|
label=f'Installing plugin from local {file.filename}',
|
||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
import quart
|
import quart
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
from .. import group
|
from .. import group
|
||||||
from .....utils import constants
|
from .....utils import constants
|
||||||
|
from .....entity.persistence.metadata import Metadata
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('system', '/api/v1/system')
|
@group.group_class('system', '/api/v1/system')
|
||||||
@@ -9,6 +13,24 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
async def initialize(self) -> None:
|
async def initialize(self) -> None:
|
||||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
|
# Read wizard_status and wizard_progress from metadata table
|
||||||
|
wizard_status = 'none'
|
||||||
|
wizard_progress = None
|
||||||
|
try:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(Metadata).where(Metadata.key.in_(['wizard_status', 'wizard_progress']))
|
||||||
|
)
|
||||||
|
for row in result:
|
||||||
|
if row.key == 'wizard_status':
|
||||||
|
wizard_status = row.value
|
||||||
|
elif row.key == 'wizard_progress':
|
||||||
|
try:
|
||||||
|
wizard_progress = json.loads(row.value)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
wizard_progress = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
return self.success(
|
return self.success(
|
||||||
data={
|
data={
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
@@ -27,17 +49,83 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
'disable_models_service', False
|
'disable_models_service', False
|
||||||
),
|
),
|
||||||
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
|
||||||
|
'wizard_status': wizard_status,
|
||||||
|
'wizard_progress': wizard_progress,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@self.route('/wizard/completed', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
"""Mark wizard status in metadata table and clear progress.
|
||||||
|
|
||||||
|
Accepts JSON body: { "status": "skipped" | "completed" }
|
||||||
|
"""
|
||||||
|
data = await quart.request.get_json(silent=True) or {}
|
||||||
|
status = data.get('status', 'completed')
|
||||||
|
if status not in ('skipped', 'completed'):
|
||||||
|
return self.http_status(400, 400, f'Invalid wizard status: {status}')
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_status')
|
||||||
|
)
|
||||||
|
if result.first():
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_status').values(value=status)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(Metadata).values(key='wizard_status', value=status)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Clear wizard progress when wizard is completed/skipped
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(Metadata).where(Metadata.key == 'wizard_progress')
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, 500, f'Failed to update wizard status: {e}')
|
||||||
|
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
|
@self.route('/wizard/progress', methods=['PUT'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
|
async def _() -> str:
|
||||||
|
"""Save wizard progress to metadata table.
|
||||||
|
|
||||||
|
Accepts JSON body with wizard state fields:
|
||||||
|
{ "step": int, "selected_adapter": str|null, "created_bot_uuid": str|null,
|
||||||
|
"bot_saved": bool, "selected_runner": str|null }
|
||||||
|
"""
|
||||||
|
data = await quart.request.get_json(silent=True) or {}
|
||||||
|
progress_json = json.dumps(data, ensure_ascii=False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(Metadata).where(Metadata.key == 'wizard_progress')
|
||||||
|
)
|
||||||
|
if result.first():
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(Metadata).where(Metadata.key == 'wizard_progress').values(value=progress_json)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(Metadata).values(key='wizard_progress', value=progress_json)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(500, 500, f'Failed to save wizard progress: {e}')
|
||||||
|
|
||||||
|
return self.success(data={})
|
||||||
|
|
||||||
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/tasks', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
task_type = quart.request.args.get('type')
|
task_type = quart.request.args.get('type')
|
||||||
|
task_kind = quart.request.args.get('kind')
|
||||||
|
|
||||||
if task_type == '':
|
if task_type == '':
|
||||||
task_type = None
|
task_type = None
|
||||||
|
if task_kind == '':
|
||||||
|
task_kind = None
|
||||||
|
|
||||||
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type))
|
return self.success(data=self.ap.task_mgr.get_tasks_dict(task_type, task_kind))
|
||||||
|
|
||||||
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/tasks/<task_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(task_id: str) -> str:
|
async def _(task_id: str) -> str:
|
||||||
|
|||||||
@@ -105,6 +105,28 @@ class HTTPController:
|
|||||||
):
|
):
|
||||||
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
||||||
path += '.html'
|
path += '.html'
|
||||||
|
elif path.startswith('home/'):
|
||||||
|
# SPA fallback for /home/* sub-routes.
|
||||||
|
# Entity detail views use query params (e.g. /home/bots?id=uuid),
|
||||||
|
# so the pre-rendered list page is served directly via path + '.html'.
|
||||||
|
# This fallback handles any remaining unmatched sub-paths.
|
||||||
|
segments = path.rstrip('/').split('/')
|
||||||
|
|
||||||
|
# Walk up parent segments looking for matching .html files
|
||||||
|
for i in range(len(segments) - 1, 0, -1):
|
||||||
|
parent_path = '/'.join(segments[:i]) + '.html'
|
||||||
|
if os.path.exists(os.path.join(frontend_path, parent_path)):
|
||||||
|
response = await quart.send_from_directory(frontend_path, parent_path, mimetype='text/html')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||||
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
response.headers['Expires'] = '0'
|
||||||
|
return response
|
||||||
|
# Final fallback to index.html for /home/* routes
|
||||||
|
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
|
||||||
else:
|
else:
|
||||||
return await quart.send_from_directory(frontend_path, '404.html')
|
return await quart.send_from_directory(frontend_path, '404.html')
|
||||||
|
|
||||||
|
|||||||
@@ -70,12 +70,17 @@ class BotService:
|
|||||||
'lark',
|
'lark',
|
||||||
]:
|
]:
|
||||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||||
|
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||||
webhook_url = f'/bots/{bot_uuid}'
|
webhook_url = f'/bots/{bot_uuid}'
|
||||||
adapter_runtime_values['webhook_url'] = webhook_url
|
adapter_runtime_values['webhook_url'] = webhook_url
|
||||||
adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}'
|
adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}'
|
||||||
|
adapter_runtime_values['extra_webhook_full_url'] = (
|
||||||
|
f'{extra_webhook_prefix}{webhook_url}' if extra_webhook_prefix else ''
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
adapter_runtime_values['webhook_url'] = None
|
adapter_runtime_values['webhook_url'] = None
|
||||||
adapter_runtime_values['webhook_full_url'] = None
|
adapter_runtime_values['webhook_full_url'] = None
|
||||||
|
adapter_runtime_values['extra_webhook_full_url'] = None
|
||||||
|
|
||||||
persistence_bot['adapter_runtime_values'] = adapter_runtime_values
|
persistence_bot['adapter_runtime_values'] = adapter_runtime_values
|
||||||
|
|
||||||
|
|||||||
@@ -105,11 +105,16 @@ class LLMModelsService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None and pipeline.config['ai']['local-agent']['model'] == '':
|
if pipeline is not None:
|
||||||
pipeline_config = pipeline.config
|
model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})
|
||||||
pipeline_config['ai']['local-agent']['model'] = model_data['uuid']
|
if not model_config.get('primary', ''):
|
||||||
pipeline_data = {'config': pipeline_config}
|
pipeline_config = pipeline.config
|
||||||
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
|
pipeline_config['ai']['local-agent']['model'] = {
|
||||||
|
'primary': model_data['uuid'],
|
||||||
|
'fallbacks': [],
|
||||||
|
}
|
||||||
|
pipeline_data = {'config': pipeline_config}
|
||||||
|
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
|
||||||
|
|
||||||
return model_data['uuid']
|
return model_data['uuid']
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,57 @@ class MonitoringService:
|
|||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
self.ap = ap
|
self.ap = ap
|
||||||
|
|
||||||
|
# ========== Cleanup Methods ==========
|
||||||
|
|
||||||
|
async def cleanup_expired_records(self, retention_days: int) -> dict[str, int]:
|
||||||
|
"""Delete monitoring records older than the specified retention period.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
retention_days: Number of days to retain records.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict mapping table name to the number of deleted rows.
|
||||||
|
"""
|
||||||
|
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
|
||||||
|
days=retention_days
|
||||||
|
)
|
||||||
|
|
||||||
|
tables_and_columns: list[tuple[str, type, sqlalchemy.Column]] = [
|
||||||
|
(
|
||||||
|
'monitoring_messages',
|
||||||
|
persistence_monitoring.MonitoringMessage,
|
||||||
|
persistence_monitoring.MonitoringMessage.timestamp,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'monitoring_llm_calls',
|
||||||
|
persistence_monitoring.MonitoringLLMCall,
|
||||||
|
persistence_monitoring.MonitoringLLMCall.timestamp,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'monitoring_embedding_calls',
|
||||||
|
persistence_monitoring.MonitoringEmbeddingCall,
|
||||||
|
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'monitoring_errors',
|
||||||
|
persistence_monitoring.MonitoringError,
|
||||||
|
persistence_monitoring.MonitoringError.timestamp,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'monitoring_sessions',
|
||||||
|
persistence_monitoring.MonitoringSession,
|
||||||
|
persistence_monitoring.MonitoringSession.last_activity,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
deleted_counts: dict[str, int] = {}
|
||||||
|
|
||||||
|
for table_name, model_cls, ts_column in tables_and_columns:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model_cls).where(ts_column < cutoff))
|
||||||
|
deleted_counts[table_name] = result.rowcount
|
||||||
|
|
||||||
|
return deleted_counts
|
||||||
|
|
||||||
# ========== Recording Methods ==========
|
# ========== Recording Methods ==========
|
||||||
|
|
||||||
async def record_message(
|
async def record_message(
|
||||||
@@ -30,6 +81,7 @@ class MonitoringService:
|
|||||||
level: str = 'info',
|
level: str = 'info',
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
|
user_name: str | None = None,
|
||||||
runner_name: str | None = None,
|
runner_name: str | None = None,
|
||||||
variables: str | None = None,
|
variables: str | None = None,
|
||||||
role: str = 'user',
|
role: str = 'user',
|
||||||
@@ -49,6 +101,7 @@ class MonitoringService:
|
|||||||
'level': level,
|
'level': level,
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'user_id': user_id,
|
'user_id': user_id,
|
||||||
|
'user_name': user_name,
|
||||||
'runner_name': runner_name,
|
'runner_name': runner_name,
|
||||||
'variables': variables,
|
'variables': variables,
|
||||||
'role': role,
|
'role': role,
|
||||||
@@ -152,6 +205,7 @@ class MonitoringService:
|
|||||||
pipeline_name: str,
|
pipeline_name: str,
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
|
user_name: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Record a new session"""
|
"""Record a new session"""
|
||||||
session_data = {
|
session_data = {
|
||||||
@@ -166,6 +220,7 @@ class MonitoringService:
|
|||||||
'is_active': True,
|
'is_active': True,
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'user_id': user_id,
|
'user_id': user_id,
|
||||||
|
'user_name': user_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -1128,3 +1183,261 @@ class MonitoringService:
|
|||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# ========== Feedback Methods ==========
|
||||||
|
|
||||||
|
async def record_feedback(
|
||||||
|
self,
|
||||||
|
feedback_id: str,
|
||||||
|
feedback_type: int,
|
||||||
|
feedback_content: str | None = None,
|
||||||
|
inaccurate_reasons: list[str] | None = None,
|
||||||
|
bot_id: str | None = None,
|
||||||
|
bot_name: str | None = None,
|
||||||
|
pipeline_id: str | None = None,
|
||||||
|
pipeline_name: str | None = None,
|
||||||
|
session_id: str | None = None,
|
||||||
|
message_id: str | None = None,
|
||||||
|
stream_id: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Record user feedback (like/dislike) from AI Bot conversation.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
feedback_id: Unique feedback identifier from platform (e.g., WeChat Work)
|
||||||
|
feedback_type: 1 = like (thumbs up), 2 = dislike (thumbs down)
|
||||||
|
feedback_content: Optional user feedback text
|
||||||
|
inaccurate_reasons: List of reasons for inaccurate response (for dislike)
|
||||||
|
bot_id: Bot ID
|
||||||
|
bot_name: Bot name
|
||||||
|
pipeline_id: Pipeline ID
|
||||||
|
pipeline_name: Pipeline name
|
||||||
|
session_id: Session ID
|
||||||
|
message_id: Message ID
|
||||||
|
stream_id: Stream ID (for WeChat Work streaming messages)
|
||||||
|
user_id: User ID
|
||||||
|
platform: Platform name (e.g., 'wecom')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The record ID
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
|
||||||
|
record_id = str(uuid.uuid4())
|
||||||
|
record_data = {
|
||||||
|
'id': record_id,
|
||||||
|
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
||||||
|
'feedback_id': feedback_id,
|
||||||
|
'feedback_type': feedback_type,
|
||||||
|
'feedback_content': feedback_content,
|
||||||
|
'inaccurate_reasons': json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None,
|
||||||
|
'bot_id': bot_id,
|
||||||
|
'bot_name': bot_name,
|
||||||
|
'pipeline_id': pipeline_id,
|
||||||
|
'pipeline_name': pipeline_name,
|
||||||
|
'session_id': session_id,
|
||||||
|
'message_id': message_id,
|
||||||
|
'stream_id': stream_id,
|
||||||
|
'user_id': user_id,
|
||||||
|
'platform': platform,
|
||||||
|
}
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
|
||||||
|
)
|
||||||
|
|
||||||
|
return record_id
|
||||||
|
|
||||||
|
async def get_feedback_stats(
|
||||||
|
self,
|
||||||
|
bot_ids: list[str] | None = None,
|
||||||
|
pipeline_ids: list[str] | None = None,
|
||||||
|
start_time: datetime.datetime | None = None,
|
||||||
|
end_time: datetime.datetime | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Get feedback statistics.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dictionary with total likes, dislikes, and breakdown by bot/pipeline
|
||||||
|
"""
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if bot_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||||
|
if pipeline_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
|
||||||
|
if start_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
||||||
|
if end_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
||||||
|
|
||||||
|
# Get total likes (feedback_type = 1)
|
||||||
|
likes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
|
||||||
|
persistence_monitoring.MonitoringFeedback.feedback_type == 1
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
likes_query = likes_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
likes_result = await self.ap.persistence_mgr.execute_async(likes_query)
|
||||||
|
total_likes = likes_result.scalar() or 0
|
||||||
|
|
||||||
|
# Get total dislikes (feedback_type = 2)
|
||||||
|
dislikes_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id)).where(
|
||||||
|
persistence_monitoring.MonitoringFeedback.feedback_type == 2
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
dislikes_query = dislikes_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
dislikes_result = await self.ap.persistence_mgr.execute_async(dislikes_query)
|
||||||
|
total_dislikes = dislikes_result.scalar() or 0
|
||||||
|
|
||||||
|
# Get total feedback count
|
||||||
|
total_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
|
||||||
|
if conditions:
|
||||||
|
total_query = total_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
total_result = await self.ap.persistence_mgr.execute_async(total_query)
|
||||||
|
total_feedback = total_result.scalar() or 0
|
||||||
|
|
||||||
|
# Calculate satisfaction rate
|
||||||
|
satisfaction_rate = (total_likes / total_feedback * 100) if total_feedback > 0 else 0
|
||||||
|
|
||||||
|
# Get feedback by bot
|
||||||
|
bot_stats_query = sqlalchemy.select(
|
||||||
|
persistence_monitoring.MonitoringFeedback.bot_id,
|
||||||
|
persistence_monitoring.MonitoringFeedback.bot_name,
|
||||||
|
sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id).label('total'),
|
||||||
|
sqlalchemy.func.sum(
|
||||||
|
sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 1, 1), else_=0)
|
||||||
|
).label('likes'),
|
||||||
|
sqlalchemy.func.sum(
|
||||||
|
sqlalchemy.case((persistence_monitoring.MonitoringFeedback.feedback_type == 2, 1), else_=0)
|
||||||
|
).label('dislikes'),
|
||||||
|
).group_by(
|
||||||
|
persistence_monitoring.MonitoringFeedback.bot_id,
|
||||||
|
persistence_monitoring.MonitoringFeedback.bot_name,
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
bot_stats_query = bot_stats_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
bot_stats_result = await self.ap.persistence_mgr.execute_async(bot_stats_query)
|
||||||
|
bot_stats = [
|
||||||
|
{
|
||||||
|
'bot_id': row.bot_id,
|
||||||
|
'bot_name': row.bot_name,
|
||||||
|
'total': row.total,
|
||||||
|
'likes': row.likes or 0,
|
||||||
|
'dislikes': row.dislikes or 0,
|
||||||
|
}
|
||||||
|
for row in bot_stats_result.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'total_feedback': total_feedback,
|
||||||
|
'total_likes': total_likes,
|
||||||
|
'total_dislikes': total_dislikes,
|
||||||
|
'satisfaction_rate': round(satisfaction_rate, 2),
|
||||||
|
'by_bot': bot_stats,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_feedback_list(
|
||||||
|
self,
|
||||||
|
bot_ids: list[str] | None = None,
|
||||||
|
pipeline_ids: list[str] | None = None,
|
||||||
|
feedback_type: int | None = None,
|
||||||
|
start_time: datetime.datetime | None = None,
|
||||||
|
end_time: datetime.datetime | None = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
"""Get feedback list with filters."""
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if bot_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||||
|
if pipeline_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
|
||||||
|
if feedback_type is not None:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.feedback_type == feedback_type)
|
||||||
|
if start_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
||||||
|
if end_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
||||||
|
|
||||||
|
# Get total count
|
||||||
|
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringFeedback.id))
|
||||||
|
if conditions:
|
||||||
|
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
||||||
|
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
# Get feedback list
|
||||||
|
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
|
||||||
|
persistence_monitoring.MonitoringFeedback.timestamp.desc()
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
query = query.where(sqlalchemy.and_(*conditions))
|
||||||
|
query = query.limit(limit).offset(offset)
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
return (
|
||||||
|
[
|
||||||
|
self.ap.persistence_mgr.serialize_model(
|
||||||
|
persistence_monitoring.MonitoringFeedback, row[0] if isinstance(row, tuple) else row
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
],
|
||||||
|
total,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def export_feedback(
|
||||||
|
self,
|
||||||
|
bot_ids: list[str] | None = None,
|
||||||
|
pipeline_ids: list[str] | None = None,
|
||||||
|
start_time: datetime.datetime | None = None,
|
||||||
|
end_time: datetime.datetime | None = None,
|
||||||
|
limit: int = 100000,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Export feedback as list of dictionaries for CSV conversion."""
|
||||||
|
conditions = []
|
||||||
|
|
||||||
|
if bot_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.bot_id.in_(bot_ids))
|
||||||
|
if pipeline_ids:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.pipeline_id.in_(pipeline_ids))
|
||||||
|
if start_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp >= start_time)
|
||||||
|
if end_time:
|
||||||
|
conditions.append(persistence_monitoring.MonitoringFeedback.timestamp <= end_time)
|
||||||
|
|
||||||
|
query = sqlalchemy.select(persistence_monitoring.MonitoringFeedback).order_by(
|
||||||
|
persistence_monitoring.MonitoringFeedback.timestamp.desc()
|
||||||
|
)
|
||||||
|
if conditions:
|
||||||
|
query = query.where(sqlalchemy.and_(*conditions))
|
||||||
|
query = query.limit(limit)
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(query)
|
||||||
|
rows = result.all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'id': row[0].id if isinstance(row, tuple) else row.id,
|
||||||
|
'timestamp': self._format_timestamp(row[0].timestamp if isinstance(row, tuple) else row.timestamp),
|
||||||
|
'feedback_id': row[0].feedback_id if isinstance(row, tuple) else row.feedback_id,
|
||||||
|
'feedback_type': 'like'
|
||||||
|
if (row[0].feedback_type if isinstance(row, tuple) else row.feedback_type) == 1
|
||||||
|
else 'dislike',
|
||||||
|
'feedback_content': row[0].feedback_content if isinstance(row, tuple) else row.feedback_content,
|
||||||
|
'inaccurate_reasons': row[0].inaccurate_reasons if isinstance(row, tuple) else row.inaccurate_reasons,
|
||||||
|
'bot_id': row[0].bot_id if isinstance(row, tuple) else row.bot_id,
|
||||||
|
'bot_name': row[0].bot_name if isinstance(row, tuple) else row.bot_name,
|
||||||
|
'pipeline_id': row[0].pipeline_id if isinstance(row, tuple) else row.pipeline_id,
|
||||||
|
'pipeline_name': row[0].pipeline_name if isinstance(row, tuple) else row.pipeline_name,
|
||||||
|
'session_id': row[0].session_id if isinstance(row, tuple) else row.session_id,
|
||||||
|
'message_id': row[0].message_id if isinstance(row, tuple) else row.message_id,
|
||||||
|
'stream_id': row[0].stream_id if isinstance(row, tuple) else row.stream_id,
|
||||||
|
'user_id': row[0].user_id if isinstance(row, tuple) else row.user_id,
|
||||||
|
'platform': row[0].platform if isinstance(row, tuple) else row.platform,
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from ..platform import botmgr as im_mgr
|
|||||||
from ..platform.webhook_pusher import WebhookPusher
|
from ..platform.webhook_pusher import WebhookPusher
|
||||||
from ..provider.session import sessionmgr as llm_session_mgr
|
from ..provider.session import sessionmgr as llm_session_mgr
|
||||||
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
||||||
|
|
||||||
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
|
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
|
||||||
from ..config import manager as config_mgr
|
from ..config import manager as config_mgr
|
||||||
from ..command import cmdmgr
|
from ..command import cmdmgr
|
||||||
@@ -30,6 +31,7 @@ from ..api.http.service import mcp as mcp_service
|
|||||||
from ..api.http.service import apikey as apikey_service
|
from ..api.http.service import apikey as apikey_service
|
||||||
from ..api.http.service import webhook as webhook_service
|
from ..api.http.service import webhook as webhook_service
|
||||||
from ..api.http.service import monitoring as monitoring_service
|
from ..api.http.service import monitoring as monitoring_service
|
||||||
|
|
||||||
from ..discover import engine as discover_engine
|
from ..discover import engine as discover_engine
|
||||||
from ..storage import mgr as storagemgr
|
from ..storage import mgr as storagemgr
|
||||||
from ..utils import logcache
|
from ..utils import logcache
|
||||||
@@ -186,6 +188,34 @@ class Application:
|
|||||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Start monitoring data cleanup task if enabled
|
||||||
|
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||||
|
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||||
|
if auto_cleanup_cfg.get('enabled', True):
|
||||||
|
retention_days = auto_cleanup_cfg.get('retention_days', 30)
|
||||||
|
check_interval_hours = auto_cleanup_cfg.get('check_interval_hours', 1)
|
||||||
|
|
||||||
|
async def monitoring_cleanup_loop():
|
||||||
|
check_interval_seconds = check_interval_hours * 3600
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
deleted = await self.monitoring_service.cleanup_expired_records(retention_days)
|
||||||
|
total_deleted = sum(deleted.values())
|
||||||
|
if total_deleted > 0:
|
||||||
|
self.logger.info(
|
||||||
|
f'Monitoring auto-cleanup: deleted {total_deleted} expired records '
|
||||||
|
f'(retention={retention_days}d): {deleted}'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f'Monitoring auto-cleanup error: {e}')
|
||||||
|
await asyncio.sleep(check_interval_seconds)
|
||||||
|
|
||||||
|
self.task_mgr.create_task(
|
||||||
|
monitoring_cleanup_loop(),
|
||||||
|
name='monitoring-cleanup',
|
||||||
|
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||||
|
)
|
||||||
|
|
||||||
self.task_mgr.create_task(
|
self.task_mgr.create_task(
|
||||||
never_ending(),
|
never_ending(),
|
||||||
name='never-ending-task',
|
name='never-ending-task',
|
||||||
|
|||||||
@@ -74,20 +74,26 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
|||||||
current = cfg
|
current = cfg
|
||||||
|
|
||||||
for i, key in enumerate(keys):
|
for i, key in enumerate(keys):
|
||||||
if not isinstance(current, dict) or key not in current:
|
if not isinstance(current, dict):
|
||||||
break
|
break
|
||||||
|
|
||||||
if i == len(keys) - 1:
|
if i == len(keys) - 1:
|
||||||
# At the final key - check if it's a scalar value
|
# At the final key
|
||||||
if isinstance(current[key], (dict, list)):
|
if key in current:
|
||||||
# Skip dict and list types
|
if isinstance(current[key], (dict, list)):
|
||||||
pass
|
# Skip dict and list types
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Valid scalar value - convert and set it
|
||||||
|
converted_value = convert_value(env_value, current[key])
|
||||||
|
current[key] = converted_value
|
||||||
else:
|
else:
|
||||||
# Valid scalar value - convert and set it
|
# Key doesn't exist yet - create it as string
|
||||||
converted_value = convert_value(env_value, current[key])
|
current[key] = env_value
|
||||||
current[key] = converted_value
|
|
||||||
else:
|
else:
|
||||||
# Navigate deeper
|
# Navigate deeper - create intermediate dict if needed
|
||||||
|
if key not in current:
|
||||||
|
current[key] = {}
|
||||||
current = current[key]
|
current = current[key]
|
||||||
|
|
||||||
return cfg
|
return cfg
|
||||||
@@ -146,16 +152,50 @@ class LoadConfigStage(stage.BootingStage):
|
|||||||
await ap.instance_config.dump_config()
|
await ap.instance_config.dump_config()
|
||||||
|
|
||||||
# load or generate instance id
|
# load or generate instance id
|
||||||
ap.instance_id = await config.load_json_config(
|
# Priority:
|
||||||
'data/labels/instance_id.json',
|
# 1. system.instance_id from config.yaml (can be set via SYSTEM__INSTANCE_ID env var)
|
||||||
template_data={
|
# 2. data/labels/instance_id.json (if file exists)
|
||||||
'instance_id': f'instance_{str(uuid.uuid4())}',
|
# 3. Generate new and save to file
|
||||||
'instance_create_ts': int(time.time()),
|
config_instance_id = ap.instance_config.data.get('system', {}).get('instance_id', '')
|
||||||
},
|
|
||||||
completion=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
constants.instance_id = ap.instance_id.data['instance_id']
|
if config_instance_id:
|
||||||
|
# Use the instance_id from config.yaml
|
||||||
|
constants.instance_id = config_instance_id
|
||||||
|
# Still load/create the file for backward compat, but don't use its value
|
||||||
|
ap.instance_id = await config.load_json_config(
|
||||||
|
'data/labels/instance_id.json',
|
||||||
|
template_data={
|
||||||
|
'instance_id': f'instance_{str(uuid.uuid4())}',
|
||||||
|
'instance_create_ts': int(time.time()),
|
||||||
|
},
|
||||||
|
completion=False,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Try loading file-based instance id
|
||||||
|
instance_id_path = os.path.join('data', 'labels', 'instance_id.json')
|
||||||
|
if os.path.exists(instance_id_path):
|
||||||
|
# File exists, read it
|
||||||
|
ap.instance_id = await config.load_json_config(
|
||||||
|
'data/labels/instance_id.json',
|
||||||
|
template_data={
|
||||||
|
'instance_id': '',
|
||||||
|
'instance_create_ts': 0,
|
||||||
|
},
|
||||||
|
completion=False,
|
||||||
|
)
|
||||||
|
constants.instance_id = ap.instance_id.data['instance_id']
|
||||||
|
else:
|
||||||
|
# Neither config nor file, generate new and save to file
|
||||||
|
new_id = f'instance_{str(uuid.uuid4())}'
|
||||||
|
ap.instance_id = await config.load_json_config(
|
||||||
|
'data/labels/instance_id.json',
|
||||||
|
template_data={
|
||||||
|
'instance_id': new_id,
|
||||||
|
'instance_create_ts': int(time.time()),
|
||||||
|
},
|
||||||
|
completion=False,
|
||||||
|
)
|
||||||
|
constants.instance_id = new_id
|
||||||
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
|
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
|
||||||
|
|
||||||
print(f'LangBot instance id: {constants.instance_id}')
|
print(f'LangBot instance id: {constants.instance_id}')
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ class TaskContext:
|
|||||||
log: str
|
log: str
|
||||||
"""Log"""
|
"""Log"""
|
||||||
|
|
||||||
|
metadata: dict
|
||||||
|
"""Structured metadata for progress reporting"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.current_action = 'default'
|
self.current_action = 'default'
|
||||||
self.log = ''
|
self.log = ''
|
||||||
|
self.metadata = {}
|
||||||
|
|
||||||
def _log(self, msg: str):
|
def _log(self, msg: str):
|
||||||
self.log += msg + '\n'
|
self.log += msg + '\n'
|
||||||
@@ -38,7 +42,7 @@ class TaskContext:
|
|||||||
self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}')
|
self._log(f'{datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")} | {self.current_action} | {msg}')
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {'current_action': self.current_action, 'log': self.log}
|
return {'current_action': self.current_action, 'log': self.log, 'metadata': self.metadata}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def new() -> TaskContext:
|
def new() -> TaskContext:
|
||||||
@@ -211,9 +215,14 @@ class AsyncTaskManager:
|
|||||||
def get_tasks_dict(
|
def get_tasks_dict(
|
||||||
self,
|
self,
|
||||||
type: str = None,
|
type: str = None,
|
||||||
|
kind: str = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
return {
|
return {
|
||||||
'tasks': [t.to_dict() for t in self.tasks if type is None or t.task_type == type],
|
'tasks': [
|
||||||
|
t.to_dict()
|
||||||
|
for t in self.tasks
|
||||||
|
if (type is None or t.task_type == type) and (kind is None or t.kind == kind)
|
||||||
|
],
|
||||||
'id_index': TaskWrapper._id_index,
|
'id_index': TaskWrapper._id_index,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,23 @@ class I18nString(pydantic.BaseModel):
|
|||||||
"""英文"""
|
"""英文"""
|
||||||
|
|
||||||
zh_Hans: typing.Optional[str] = None
|
zh_Hans: typing.Optional[str] = None
|
||||||
"""中文"""
|
"""简体中文"""
|
||||||
|
|
||||||
|
zh_Hant: typing.Optional[str] = None
|
||||||
|
"""繁体中文"""
|
||||||
|
|
||||||
ja_JP: typing.Optional[str] = None
|
ja_JP: typing.Optional[str] = None
|
||||||
"""日文"""
|
"""日文"""
|
||||||
|
|
||||||
|
th_TH: typing.Optional[str] = None
|
||||||
|
"""泰文"""
|
||||||
|
|
||||||
|
vi_VN: typing.Optional[str] = None
|
||||||
|
"""越南文"""
|
||||||
|
|
||||||
|
es_ES: typing.Optional[str] = None
|
||||||
|
"""西班牙文"""
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
"""转换为字典"""
|
"""转换为字典"""
|
||||||
dic = {}
|
dic = {}
|
||||||
@@ -29,8 +41,16 @@ class I18nString(pydantic.BaseModel):
|
|||||||
dic['en_US'] = self.en_US
|
dic['en_US'] = self.en_US
|
||||||
if self.zh_Hans is not None:
|
if self.zh_Hans is not None:
|
||||||
dic['zh_Hans'] = self.zh_Hans
|
dic['zh_Hans'] = self.zh_Hans
|
||||||
|
if self.zh_Hant is not None:
|
||||||
|
dic['zh_Hant'] = self.zh_Hant
|
||||||
if self.ja_JP is not None:
|
if self.ja_JP is not None:
|
||||||
dic['ja_JP'] = self.ja_JP
|
dic['ja_JP'] = self.ja_JP
|
||||||
|
if self.th_TH is not None:
|
||||||
|
dic['th_TH'] = self.th_TH
|
||||||
|
if self.vi_VN is not None:
|
||||||
|
dic['vi_VN'] = self.vi_VN
|
||||||
|
if self.es_ES is not None:
|
||||||
|
dic['es_ES'] = self.es_ES
|
||||||
return dic
|
return dic
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class MonitoringMessage(Base):
|
|||||||
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
|
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
|
||||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
|
||||||
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
|
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
|
||||||
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
|
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
|
||||||
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
|
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
|
||||||
@@ -64,6 +65,7 @@ class MonitoringSession(Base):
|
|||||||
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
|
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
|
||||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
|
||||||
|
|
||||||
|
|
||||||
class MonitoringError(Base):
|
class MonitoringError(Base):
|
||||||
@@ -104,3 +106,26 @@ class MonitoringEmbeddingCall(Base):
|
|||||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
|
call_type = sqlalchemy.Column(sqlalchemy.String(50), nullable=True) # embedding, retrieve
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringFeedback(Base):
|
||||||
|
"""User feedback records (like/dislike) from AI Bot conversations"""
|
||||||
|
|
||||||
|
__tablename__ = 'monitoring_feedback'
|
||||||
|
|
||||||
|
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||||
|
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
||||||
|
feedback_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||||
|
feedback_type = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # 1=like, 2=dislike
|
||||||
|
feedback_content = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # User feedback text
|
||||||
|
inaccurate_reasons = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # JSON list of inaccurate reasons
|
||||||
|
# Context fields
|
||||||
|
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
stream_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
||||||
|
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
|
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # e.g., wecom
|
||||||
|
|||||||
@@ -2,18 +2,16 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import typing
|
import typing
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from . import database, migration
|
from . import database, migration
|
||||||
from ..entity.persistence import base, pipeline, metadata, model as persistence_model
|
from ..entity.persistence import base, metadata, model as persistence_model
|
||||||
from ..entity import persistence
|
from ..entity import persistence
|
||||||
from ..core import app
|
from ..core import app
|
||||||
from ..utils import constants, importutil
|
from ..utils import constants, importutil
|
||||||
from ..api.http.service import pipeline as pipeline_service
|
|
||||||
from . import databases, migrations
|
from . import databases, migrations
|
||||||
|
|
||||||
importutil.import_modules_in_pkg(databases)
|
importutil.import_modules_in_pkg(databases)
|
||||||
@@ -78,7 +76,6 @@ class PersistenceManager:
|
|||||||
|
|
||||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||||
|
|
||||||
await self.write_default_pipeline()
|
|
||||||
await self.write_space_model_providers()
|
await self.write_space_model_providers()
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
@@ -101,29 +98,6 @@ class PersistenceManager:
|
|||||||
if row is None:
|
if row is None:
|
||||||
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
|
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
|
||||||
|
|
||||||
async def write_default_pipeline(self):
|
|
||||||
# write default pipeline
|
|
||||||
result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline))
|
|
||||||
default_pipeline_uuid = None
|
|
||||||
if result.first() is None:
|
|
||||||
self.ap.logger.info('Creating default pipeline...')
|
|
||||||
|
|
||||||
pipeline_config = json.loads(importutil.read_resource_file('templates/default-pipeline-config.json'))
|
|
||||||
|
|
||||||
default_pipeline_uuid = str(uuid.uuid4())
|
|
||||||
pipeline_data = {
|
|
||||||
'uuid': default_pipeline_uuid,
|
|
||||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
|
||||||
'stages': pipeline_service.default_stage_order,
|
|
||||||
'is_default': True,
|
|
||||||
'name': 'ChatPipeline',
|
|
||||||
'description': 'Default pipeline, new bots will be bound to this pipeline | 默认提供的流水线,您配置的机器人将自动绑定到此流水线',
|
|
||||||
'config': pipeline_config,
|
|
||||||
'extensions_preferences': {},
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
|
|
||||||
|
|
||||||
async def write_space_model_providers(self):
|
async def write_space_model_providers(self):
|
||||||
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
|
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
|
||||||
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
|
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import json
|
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
from .. import migration
|
from .. import migration
|
||||||
|
|
||||||
@@ -9,20 +7,22 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
|||||||
"""Migrate to unified Knowledge Engine plugin architecture.
|
"""Migrate to unified Knowledge Engine plugin architecture.
|
||||||
|
|
||||||
Changes:
|
Changes:
|
||||||
- Add knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings columns to knowledge_bases
|
- Backup existing knowledge_bases data to knowledge_bases_backup
|
||||||
- Migrate existing top_k values into retrieval_settings JSON
|
- Clear knowledge_bases table and add new plugin architecture columns
|
||||||
- Migrate existing embedding_model_uuid into creation_settings JSON
|
- Drop old columns (PostgreSQL only; SQLite leaves them unmapped)
|
||||||
- Drop embedding_model_uuid and top_k columns (PostgreSQL only; SQLite leaves them unmapped)
|
- Preserve external_knowledge_bases table as-is for future migration
|
||||||
- Drop external_knowledge_bases table (no longer needed; external KB data is not migrated)
|
- Set rag_plugin_migration_needed flag in metadata if old data exists
|
||||||
"""
|
"""
|
||||||
|
|
||||||
async def upgrade(self):
|
async def upgrade(self):
|
||||||
"""Upgrade"""
|
"""Upgrade"""
|
||||||
|
has_internal_data = await self._backup_knowledge_bases()
|
||||||
|
has_external_data = await self._check_external_knowledge_bases()
|
||||||
|
await self._clear_knowledge_bases()
|
||||||
await self._add_columns_to_knowledge_bases()
|
await self._add_columns_to_knowledge_bases()
|
||||||
await self._migrate_top_k_to_retrieval_settings()
|
|
||||||
await self._migrate_embedding_model_uuid_to_creation_settings()
|
|
||||||
await self._drop_old_columns()
|
await self._drop_old_columns()
|
||||||
await self._drop_external_knowledge_bases_table()
|
if has_internal_data or has_external_data:
|
||||||
|
await self._set_migration_flag()
|
||||||
|
|
||||||
async def _get_table_columns(self, table_name: str) -> list[str]:
|
async def _get_table_columns(self, table_name: str) -> list[str]:
|
||||||
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
|
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
|
||||||
@@ -57,6 +57,50 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
|||||||
)
|
)
|
||||||
return result.first() is not None
|
return result.first() is not None
|
||||||
|
|
||||||
|
async def _backup_knowledge_bases(self) -> bool:
|
||||||
|
"""Backup knowledge_bases data. Returns True if data was backed up."""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases;'))
|
||||||
|
count = result.scalar()
|
||||||
|
if count == 0:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Drop backup table if it already exists (from a previous failed migration)
|
||||||
|
if await self._table_exists('knowledge_bases_backup'):
|
||||||
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE knowledge_bases_backup;'))
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('CREATE TABLE knowledge_bases_backup AS SELECT * FROM knowledge_bases;')
|
||||||
|
)
|
||||||
|
self.ap.logger.info(
|
||||||
|
'Backed up %d knowledge base(s) to knowledge_bases_backup table.',
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _check_external_knowledge_bases(self) -> bool:
|
||||||
|
"""Check if external_knowledge_bases table exists and has data.
|
||||||
|
|
||||||
|
The table is preserved as-is (not dropped) for future migration.
|
||||||
|
"""
|
||||||
|
if not await self._table_exists('external_knowledge_bases'):
|
||||||
|
return False
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')
|
||||||
|
)
|
||||||
|
count = result.scalar()
|
||||||
|
if count > 0:
|
||||||
|
self.ap.logger.info(
|
||||||
|
'Found %d external knowledge base(s) in external_knowledge_bases table. '
|
||||||
|
'Table preserved for future migration.',
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
return count > 0
|
||||||
|
|
||||||
|
async def _clear_knowledge_bases(self):
|
||||||
|
"""Clear all rows from knowledge_bases table (preserve table structure)."""
|
||||||
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DELETE FROM knowledge_bases;'))
|
||||||
|
|
||||||
async def _add_columns_to_knowledge_bases(self):
|
async def _add_columns_to_knowledge_bases(self):
|
||||||
"""Add new RAG plugin architecture columns to knowledge_bases table."""
|
"""Add new RAG plugin architecture columns to knowledge_bases table."""
|
||||||
columns = await self._get_table_columns('knowledge_bases')
|
columns = await self._get_table_columns('knowledge_bases')
|
||||||
@@ -74,73 +118,6 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
|||||||
sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};')
|
sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};')
|
||||||
)
|
)
|
||||||
|
|
||||||
# For existing knowledge bases without knowledge_engine_plugin_id,
|
|
||||||
# set collection_id = uuid (same default as new KBs)
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('UPDATE knowledge_bases SET collection_id = uuid WHERE collection_id IS NULL;')
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _migrate_top_k_to_retrieval_settings(self):
|
|
||||||
"""Migrate existing top_k values into retrieval_settings JSON."""
|
|
||||||
columns = await self._get_table_columns('knowledge_bases')
|
|
||||||
if 'top_k' not in columns:
|
|
||||||
return
|
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'SELECT uuid, top_k FROM knowledge_bases WHERE top_k IS NOT NULL AND retrieval_settings IS NULL;'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
rows = result.fetchall()
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
kb_uuid = row[0]
|
|
||||||
top_k = row[1]
|
|
||||||
retrieval_settings = json.dumps({'top_k': top_k})
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text('UPDATE knowledge_bases SET retrieval_settings = :rs WHERE uuid = :uuid;').bindparams(
|
|
||||||
rs=retrieval_settings, uuid=kb_uuid
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _migrate_embedding_model_uuid_to_creation_settings(self):
|
|
||||||
"""Migrate existing embedding_model_uuid into creation_settings JSON."""
|
|
||||||
columns = await self._get_table_columns('knowledge_bases')
|
|
||||||
if 'embedding_model_uuid' not in columns:
|
|
||||||
return
|
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'SELECT uuid, embedding_model_uuid, creation_settings FROM knowledge_bases '
|
|
||||||
"WHERE embedding_model_uuid IS NOT NULL AND embedding_model_uuid != '';"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
rows = result.fetchall()
|
|
||||||
|
|
||||||
for row in rows:
|
|
||||||
kb_uuid = row[0]
|
|
||||||
emb_uuid = row[1]
|
|
||||||
existing_settings = row[2]
|
|
||||||
|
|
||||||
if existing_settings and isinstance(existing_settings, str):
|
|
||||||
try:
|
|
||||||
settings = json.loads(existing_settings)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
settings = {}
|
|
||||||
elif isinstance(existing_settings, dict):
|
|
||||||
settings = existing_settings
|
|
||||||
else:
|
|
||||||
settings = {}
|
|
||||||
|
|
||||||
if 'embedding_model_uuid' not in settings:
|
|
||||||
settings['embedding_model_uuid'] = emb_uuid
|
|
||||||
new_settings = json.dumps(settings)
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.text(
|
|
||||||
'UPDATE knowledge_bases SET creation_settings = :cs WHERE uuid = :uuid;'
|
|
||||||
).bindparams(cs=new_settings, uuid=kb_uuid)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _drop_old_columns(self):
|
async def _drop_old_columns(self):
|
||||||
"""Drop embedding_model_uuid and top_k columns (PostgreSQL only).
|
"""Drop embedding_model_uuid and top_k columns (PostgreSQL only).
|
||||||
|
|
||||||
@@ -162,22 +139,22 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
|||||||
sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;')
|
sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;')
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _drop_external_knowledge_bases_table(self):
|
async def _set_migration_flag(self):
|
||||||
"""Drop the external_knowledge_bases table if it exists."""
|
"""Set rag_plugin_migration_needed flag in metadata table."""
|
||||||
if await self._table_exists('external_knowledge_bases'):
|
# Check if the key already exists
|
||||||
# Log existing external KBs before dropping, so users are aware of data loss
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
rows = await self.ap.persistence_mgr.execute_async(
|
sqlalchemy.text("SELECT value FROM metadata WHERE key = 'rag_plugin_migration_needed';")
|
||||||
sqlalchemy.text('SELECT * FROM external_knowledge_bases;')
|
)
|
||||||
|
row = result.first()
|
||||||
|
if row is not None:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text("UPDATE metadata SET value = 'true' WHERE key = 'rag_plugin_migration_needed';")
|
||||||
)
|
)
|
||||||
existing = rows.fetchall()
|
else:
|
||||||
if existing:
|
await self.ap.persistence_mgr.execute_async(
|
||||||
self.ap.logger.warning(
|
sqlalchemy.text("INSERT INTO metadata (key, value) VALUES ('rag_plugin_migration_needed', 'true');")
|
||||||
'Dropping external_knowledge_bases table with %d existing record(s). '
|
)
|
||||||
'These external KB configurations will be removed: %s',
|
self.ap.logger.info('Set rag_plugin_migration_needed=true in metadata.')
|
||||||
len(existing),
|
|
||||||
[dict(row._mapping) for row in existing],
|
|
||||||
)
|
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE external_knowledge_bases;'))
|
|
||||||
|
|
||||||
async def downgrade(self):
|
async def downgrade(self):
|
||||||
"""Downgrade"""
|
"""Downgrade"""
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from .. import migration
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(21)
|
||||||
|
class DBMigrateMergeExceptionHandling(migration.DBMigration):
|
||||||
|
"""Merge hide-exception and block-failed-request-output into a single exception-handling select option,
|
||||||
|
and add failure-hint field.
|
||||||
|
|
||||||
|
Conversion logic:
|
||||||
|
- block-failed-request-output=true -> exception-handling: hide
|
||||||
|
- hide-exception=true -> exception-handling: show-hint
|
||||||
|
- hide-exception=false -> exception-handling: show-error
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
"""Upgrade"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||||
|
)
|
||||||
|
pipelines = result.fetchall()
|
||||||
|
|
||||||
|
current_version = self.ap.ver_mgr.get_current_version()
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
if 'output' not in config:
|
||||||
|
config['output'] = {}
|
||||||
|
if 'misc' not in config['output']:
|
||||||
|
config['output']['misc'] = {}
|
||||||
|
|
||||||
|
misc = config['output']['misc']
|
||||||
|
|
||||||
|
# Determine new exception-handling value from legacy fields
|
||||||
|
hide_exception = misc.get('hide-exception', True)
|
||||||
|
block_failed = misc.get('block-failed-request-output', False)
|
||||||
|
|
||||||
|
if block_failed:
|
||||||
|
exception_handling = 'hide'
|
||||||
|
elif hide_exception:
|
||||||
|
exception_handling = 'show-hint'
|
||||||
|
else:
|
||||||
|
exception_handling = 'show-error'
|
||||||
|
|
||||||
|
misc['exception-handling'] = exception_handling
|
||||||
|
|
||||||
|
# Add failure-hint with default value
|
||||||
|
misc['failure-hint'] = 'Request failed.'
|
||||||
|
|
||||||
|
# Remove legacy fields
|
||||||
|
misc.pop('hide-exception', None)
|
||||||
|
|
||||||
|
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"""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import sqlalchemy
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(22)
|
||||||
|
class DBMigrateMonitoringUserId(migration.DBMigration):
|
||||||
|
"""Add user_id and user_name columns to monitoring_sessions table
|
||||||
|
|
||||||
|
This migration adds the missing user_id column and also ensures user_name
|
||||||
|
column exists (in case migration 21 failed or was skipped).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _table_exists(self, table_name: str) -> bool:
|
||||||
|
"""Check if a table exists (works for both SQLite and PostgreSQL)."""
|
||||||
|
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
|
||||||
|
).bindparams(table_name=table_name)
|
||||||
|
)
|
||||||
|
return bool(result.scalar())
|
||||||
|
else:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
|
||||||
|
table_name=table_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.first() is not None
|
||||||
|
|
||||||
|
async def _get_table_columns(self, table_name: str) -> list[str]:
|
||||||
|
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
|
||||||
|
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;'
|
||||||
|
).bindparams(table_name=table_name)
|
||||||
|
)
|
||||||
|
return [row[0] for row in result.fetchall()]
|
||||||
|
else:
|
||||||
|
if not table_name.isidentifier():
|
||||||
|
raise ValueError(f'Invalid table name: {table_name}')
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
|
||||||
|
return [row[1] for row in result.fetchall()]
|
||||||
|
|
||||||
|
async def _add_column_if_not_exists(self, table_name: str, column_name: str, column_type: str):
|
||||||
|
"""Add a column to a table if it does not already exist."""
|
||||||
|
columns = await self._get_table_columns(table_name)
|
||||||
|
if column_name in columns:
|
||||||
|
self.ap.logger.debug('%s column already exists in %s.', column_name, table_name)
|
||||||
|
return
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type};')
|
||||||
|
)
|
||||||
|
self.ap.logger.info('Added %s column to %s table.', column_name, table_name)
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
# Check if monitoring_sessions table exists
|
||||||
|
if not await self._table_exists('monitoring_sessions'):
|
||||||
|
self.ap.logger.warning('monitoring_sessions table does not exist, skipping migration.')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add user_id column to monitoring_sessions table
|
||||||
|
await self._add_column_if_not_exists('monitoring_sessions', 'user_id', 'VARCHAR(255)')
|
||||||
|
|
||||||
|
# Add user_name column to monitoring_sessions table (in case migration 21 failed)
|
||||||
|
await self._add_column_if_not_exists('monitoring_sessions', 'user_name', 'VARCHAR(255)')
|
||||||
|
|
||||||
|
# Add user_name column to monitoring_messages table (in case migration 21 failed)
|
||||||
|
if await self._table_exists('monitoring_messages'):
|
||||||
|
await self._add_column_if_not_exists('monitoring_messages', 'user_name', 'VARCHAR(255)')
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
from .. import migration
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(23)
|
||||||
|
class DBMigrateModelFallbackConfig(migration.DBMigration):
|
||||||
|
"""Convert model field from plain UUID string to object with primary/fallbacks"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
"""Upgrade"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||||
|
)
|
||||||
|
pipelines = result.fetchall()
|
||||||
|
|
||||||
|
current_version = self.ap.ver_mgr.get_current_version()
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
if 'ai' not in config or 'local-agent' not in config['ai']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_agent = config['ai']['local-agent']
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# Convert model from string to object
|
||||||
|
model_value = local_agent.get('model', '')
|
||||||
|
if isinstance(model_value, str):
|
||||||
|
local_agent['model'] = {
|
||||||
|
'primary': model_value,
|
||||||
|
'fallbacks': [],
|
||||||
|
}
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Remove leftover fallback-models field if present
|
||||||
|
if 'fallback-models' in local_agent:
|
||||||
|
del local_agent['fallback-models']
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 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"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||||
|
)
|
||||||
|
pipelines = result.fetchall()
|
||||||
|
|
||||||
|
current_version = self.ap.ver_mgr.get_current_version()
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
if 'ai' not in config or 'local-agent' not in config['ai']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_agent = config['ai']['local-agent']
|
||||||
|
|
||||||
|
# Convert model from object back to string
|
||||||
|
model_value = local_agent.get('model', '')
|
||||||
|
if isinstance(model_value, dict):
|
||||||
|
local_agent['model'] = model_value.get('primary', '')
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 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},
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from .. import migration
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(24)
|
||||||
|
class DBMigrateWecomBotWebSocketMode(migration.DBMigration):
|
||||||
|
"""Add enable-webhook field to existing wecombot adapter configs.
|
||||||
|
|
||||||
|
Existing wecombot bots were all using webhook mode, so we set
|
||||||
|
enable-webhook=true to preserve their behavior after the new
|
||||||
|
WebSocket long connection mode is introduced as default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
"""Upgrade"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text("SELECT uuid, adapter_config FROM bots WHERE adapter = 'wecombot'")
|
||||||
|
)
|
||||||
|
bots = result.fetchall()
|
||||||
|
|
||||||
|
for bot_row in bots:
|
||||||
|
bot_uuid = bot_row[0]
|
||||||
|
adapter_config = json.loads(bot_row[1]) if isinstance(bot_row[1], str) else bot_row[1]
|
||||||
|
|
||||||
|
if 'enable-webhook' in adapter_config:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine mode based on existing config: if webhook fields are present, keep webhook mode
|
||||||
|
has_webhook_config = bool(
|
||||||
|
adapter_config.get('Token') and adapter_config.get('EncodingAESKey') and adapter_config.get('Corpid')
|
||||||
|
)
|
||||||
|
adapter_config['enable-webhook'] = has_webhook_config
|
||||||
|
|
||||||
|
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('UPDATE bots SET adapter_config = :config::jsonb WHERE uuid = :uuid'),
|
||||||
|
{'config': json.dumps(adapter_config), 'uuid': bot_uuid},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('UPDATE bots SET adapter_config = :config WHERE uuid = :uuid'),
|
||||||
|
{'config': json.dumps(adapter_config), 'uuid': bot_uuid},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
"""Downgrade"""
|
||||||
|
pass
|
||||||
105
src/langbot/pkg/pipeline/config_coercion.py
Normal file
105
src/langbot/pkg/pipeline/config_coercion.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# metadata type -> coercion function
|
||||||
|
_COERCE_MAP = {
|
||||||
|
'integer': lambda v: int(v),
|
||||||
|
'number': lambda v: float(v),
|
||||||
|
'float': lambda v: float(v),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_bool(v):
|
||||||
|
if isinstance(v, bool):
|
||||||
|
return v
|
||||||
|
if isinstance(v, str):
|
||||||
|
if v.lower() == 'true':
|
||||||
|
return True
|
||||||
|
if v.lower() == 'false':
|
||||||
|
return False
|
||||||
|
raise ValueError(f'Cannot convert string {v!r} to bool')
|
||||||
|
return bool(v)
|
||||||
|
|
||||||
|
|
||||||
|
def _coerce_value(value, expected_type: str):
|
||||||
|
"""Convert a single value to the expected type.
|
||||||
|
|
||||||
|
Returns the converted value, or the original value if no conversion needed.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return value
|
||||||
|
|
||||||
|
if expected_type == 'boolean':
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return _coerce_bool(value)
|
||||||
|
|
||||||
|
coerce_fn = _COERCE_MAP.get(expected_type)
|
||||||
|
if coerce_fn is None:
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Already the correct type
|
||||||
|
if expected_type == 'integer' and isinstance(value, int) and not isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
if expected_type in ('number', 'float') and isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||||
|
return float(value)
|
||||||
|
|
||||||
|
return coerce_fn(value)
|
||||||
|
|
||||||
|
|
||||||
|
def coerce_pipeline_config(
|
||||||
|
config: dict,
|
||||||
|
*metadata_list: dict,
|
||||||
|
) -> None:
|
||||||
|
"""Coerce pipeline config values according to metadata type definitions.
|
||||||
|
|
||||||
|
Walks each metadata dict (trigger, safety, ai, output) and converts
|
||||||
|
config values in-place so that strings coming from the JSON column are
|
||||||
|
cast to their declared types (integer, number/float, boolean).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
config: The pipeline config dict to modify in-place.
|
||||||
|
*metadata_list: Metadata dicts loaded from the YAML templates.
|
||||||
|
"""
|
||||||
|
for meta in metadata_list:
|
||||||
|
section_name = meta.get('name')
|
||||||
|
if not section_name or section_name not in config:
|
||||||
|
continue
|
||||||
|
|
||||||
|
section = config[section_name]
|
||||||
|
if not isinstance(section, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for stage_def in meta.get('stages', []):
|
||||||
|
stage_name = stage_def.get('name')
|
||||||
|
if not stage_name or stage_name not in section:
|
||||||
|
continue
|
||||||
|
|
||||||
|
stage_config = section[stage_name]
|
||||||
|
if not isinstance(stage_config, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
for field_def in stage_def.get('config', []):
|
||||||
|
field_name = field_def.get('name')
|
||||||
|
field_type = field_def.get('type')
|
||||||
|
if not field_name or not field_type or field_name not in stage_config:
|
||||||
|
continue
|
||||||
|
|
||||||
|
old_value = stage_config[field_name]
|
||||||
|
try:
|
||||||
|
new_value = _coerce_value(old_value, field_type)
|
||||||
|
if new_value is not old_value:
|
||||||
|
stage_config[field_name] = new_value
|
||||||
|
except (ValueError, TypeError) as e:
|
||||||
|
logger.warning(
|
||||||
|
'Failed to coerce config %s.%s.%s (%r) to %s: %s',
|
||||||
|
section_name,
|
||||||
|
stage_name,
|
||||||
|
field_name,
|
||||||
|
old_value,
|
||||||
|
field_type,
|
||||||
|
e,
|
||||||
|
)
|
||||||
@@ -22,13 +22,10 @@ class LongTextProcessStage(stage.PipelineStage):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
strategy_impl: strategy.LongTextStrategy | None
|
strategy_impl: strategy.LongTextStrategy | None
|
||||||
is_split: bool
|
|
||||||
|
|
||||||
async def initialize(self, pipeline_config: dict):
|
async def initialize(self, pipeline_config: dict):
|
||||||
config = pipeline_config['output']['long-text-processing']
|
config = pipeline_config['output']['long-text-processing']
|
||||||
|
|
||||||
self.is_split = config['strategy'] == 'split'
|
|
||||||
|
|
||||||
if config['strategy'] == 'none':
|
if config['strategy'] == 'none':
|
||||||
self.strategy_impl = None
|
self.strategy_impl = None
|
||||||
return
|
return
|
||||||
@@ -93,23 +90,8 @@ class LongTextProcessStage(stage.PipelineStage):
|
|||||||
len(str(query.resp_message_chain[-1]))
|
len(str(query.resp_message_chain[-1]))
|
||||||
> query.pipeline_config['output']['long-text-processing']['threshold']
|
> query.pipeline_config['output']['long-text-processing']['threshold']
|
||||||
):
|
):
|
||||||
if self.is_split:
|
query.resp_message_chain[-1] = platform_message.MessageChain(
|
||||||
original_text = str(query.resp_message_chain[-1])
|
await self.strategy_impl.process(str(query.resp_message_chain[-1]), query)
|
||||||
threshold = query.pipeline_config['output']['long-text-processing']['threshold']
|
)
|
||||||
segments = self.strategy_impl.split_text(original_text, threshold)
|
|
||||||
# Replace the last chain with the first segment, store extra segments separately
|
|
||||||
# to avoid interfering with existing multi-chain scenarios (e.g. agent tool calls)
|
|
||||||
query.resp_message_chain[-1] = platform_message.MessageChain(
|
|
||||||
[platform_message.Plain(text=segments[0])]
|
|
||||||
)
|
|
||||||
if len(segments) > 1:
|
|
||||||
query.set_variable(
|
|
||||||
'_longtext_split_extra_chains',
|
|
||||||
[platform_message.MessageChain([platform_message.Plain(text=seg)]) for seg in segments[1:]],
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
query.resp_message_chain[-1] = platform_message.MessageChain(
|
|
||||||
await self.strategy_impl.process(str(query.resp_message_chain[-1]), query)
|
|
||||||
)
|
|
||||||
|
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|||||||
@@ -1,224 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
from .. import strategy as strategy_model
|
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
|
|
||||||
|
|
||||||
@strategy_model.strategy_class('split')
|
|
||||||
class SplitStrategy(strategy_model.LongTextStrategy):
|
|
||||||
"""Split long text into multiple message segments with Markdown awareness."""
|
|
||||||
|
|
||||||
async def process(self, message: str, query: pipeline_query.Query) -> list[platform_message.MessageComponent]:
|
|
||||||
segments = self.split_text(
|
|
||||||
message,
|
|
||||||
query.pipeline_config['output']['long-text-processing']['threshold'],
|
|
||||||
)
|
|
||||||
return [platform_message.Plain(text=segments[0])] if segments else []
|
|
||||||
|
|
||||||
def split_text(self, text: str, max_length: int) -> list[str]:
|
|
||||||
"""Split text into segments respecting Markdown structure.
|
|
||||||
|
|
||||||
Priority:
|
|
||||||
1. Markdown structural boundaries (headings, code blocks, horizontal rules)
|
|
||||||
2. Paragraph breaks (blank lines)
|
|
||||||
3. List item boundaries
|
|
||||||
4. Line breaks
|
|
||||||
5. Hard cut (fallback)
|
|
||||||
"""
|
|
||||||
if len(text) <= max_length:
|
|
||||||
return [text]
|
|
||||||
|
|
||||||
blocks = self._parse_markdown_blocks(text)
|
|
||||||
return self._merge_blocks(blocks, max_length)
|
|
||||||
|
|
||||||
def _parse_markdown_blocks(self, text: str) -> list[str]:
|
|
||||||
"""Parse text into Markdown-aware blocks.
|
|
||||||
|
|
||||||
Keeps code blocks intact and splits the rest by structural elements.
|
|
||||||
"""
|
|
||||||
blocks: list[str] = []
|
|
||||||
lines = text.split('\n')
|
|
||||||
current_block: list[str] = []
|
|
||||||
in_code_block = False
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
stripped = line.strip()
|
|
||||||
|
|
||||||
# Toggle fenced code block state
|
|
||||||
if stripped.startswith('```'):
|
|
||||||
if in_code_block:
|
|
||||||
# End of code block - close it as one block
|
|
||||||
current_block.append(line)
|
|
||||||
blocks.append('\n'.join(current_block))
|
|
||||||
current_block = []
|
|
||||||
in_code_block = False
|
|
||||||
continue
|
|
||||||
else:
|
|
||||||
# Start of code block - flush current block first
|
|
||||||
if current_block:
|
|
||||||
blocks.append('\n'.join(current_block))
|
|
||||||
current_block = []
|
|
||||||
current_block.append(line)
|
|
||||||
in_code_block = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
if in_code_block:
|
|
||||||
current_block.append(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Heading (# ...) - start a new block
|
|
||||||
if re.match(r'^#{1,6}\s', stripped):
|
|
||||||
if current_block:
|
|
||||||
blocks.append('\n'.join(current_block))
|
|
||||||
current_block = []
|
|
||||||
current_block.append(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Horizontal rule (---, ***, ___) - start a new block
|
|
||||||
if re.match(r'^(-{3,}|\*{3,}|_{3,})\s*$', stripped):
|
|
||||||
if current_block:
|
|
||||||
blocks.append('\n'.join(current_block))
|
|
||||||
current_block = []
|
|
||||||
blocks.append(line)
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Blank line - paragraph boundary
|
|
||||||
if stripped == '':
|
|
||||||
if current_block:
|
|
||||||
current_block.append(line)
|
|
||||||
blocks.append('\n'.join(current_block))
|
|
||||||
current_block = []
|
|
||||||
continue
|
|
||||||
|
|
||||||
current_block.append(line)
|
|
||||||
|
|
||||||
# Flush remaining (including unclosed code blocks)
|
|
||||||
if current_block:
|
|
||||||
blocks.append('\n'.join(current_block))
|
|
||||||
|
|
||||||
return [b for b in blocks if b.strip()]
|
|
||||||
|
|
||||||
def _merge_blocks(self, blocks: list[str], max_length: int) -> list[str]:
|
|
||||||
"""Merge small blocks greedily until approaching max_length.
|
|
||||||
|
|
||||||
If a single block exceeds max_length, split it by lines as fallback.
|
|
||||||
"""
|
|
||||||
segments: list[str] = []
|
|
||||||
current = ''
|
|
||||||
|
|
||||||
for block in blocks:
|
|
||||||
candidate = (current + '\n\n' + block) if current else block
|
|
||||||
|
|
||||||
if len(candidate) <= max_length:
|
|
||||||
current = candidate
|
|
||||||
else:
|
|
||||||
# Flush current segment
|
|
||||||
if current:
|
|
||||||
segments.append(current)
|
|
||||||
|
|
||||||
# Check if this single block fits
|
|
||||||
if len(block) <= max_length:
|
|
||||||
current = block
|
|
||||||
else:
|
|
||||||
# Block too large - split it by lines
|
|
||||||
for part in self._split_large_block(block, max_length):
|
|
||||||
segments.append(part)
|
|
||||||
current = ''
|
|
||||||
|
|
||||||
if current:
|
|
||||||
segments.append(current)
|
|
||||||
|
|
||||||
return [s for s in segments if s.strip()]
|
|
||||||
|
|
||||||
def _split_large_block(self, block: str, max_length: int) -> list[str]:
|
|
||||||
"""Split an oversized block by lines, preserving code block fences.
|
|
||||||
|
|
||||||
For single-line plain text (no newlines), falls back to splitting at
|
|
||||||
natural language boundaries (spaces, punctuation).
|
|
||||||
"""
|
|
||||||
lines = block.split('\n')
|
|
||||||
|
|
||||||
# Single long line with no newlines - use plain text splitting
|
|
||||||
if len(lines) == 1:
|
|
||||||
return self._split_plain_text(block, max_length)
|
|
||||||
|
|
||||||
is_code_block = lines[0].strip().startswith('```')
|
|
||||||
|
|
||||||
segments: list[str] = []
|
|
||||||
current_lines: list[str] = []
|
|
||||||
current_len = 0
|
|
||||||
|
|
||||||
# For code blocks, track the opening fence to re-apply on continuations
|
|
||||||
code_fence = lines[0] if is_code_block else ''
|
|
||||||
|
|
||||||
for i, line in enumerate(lines):
|
|
||||||
line_len = len(line) + 1 # +1 for newline
|
|
||||||
|
|
||||||
# Single line exceeds limit on its own - split it first
|
|
||||||
if line_len > max_length:
|
|
||||||
if current_lines:
|
|
||||||
seg = '\n'.join(current_lines)
|
|
||||||
if is_code_block and not seg.rstrip().endswith('```'):
|
|
||||||
seg += '\n```'
|
|
||||||
segments.append(seg)
|
|
||||||
current_lines = []
|
|
||||||
current_len = 0
|
|
||||||
|
|
||||||
for part in self._split_plain_text(line, max_length):
|
|
||||||
segments.append(part)
|
|
||||||
continue
|
|
||||||
|
|
||||||
if current_len + line_len > max_length and current_lines:
|
|
||||||
segment = '\n'.join(current_lines)
|
|
||||||
# Close code block fence if splitting mid-code-block
|
|
||||||
if is_code_block and not segment.rstrip().endswith('```'):
|
|
||||||
segment += '\n```'
|
|
||||||
segments.append(segment)
|
|
||||||
|
|
||||||
current_lines = []
|
|
||||||
current_len = 0
|
|
||||||
# Re-open code block fence for continuation
|
|
||||||
if is_code_block and i < len(lines) - 1 and not line.strip().startswith('```'):
|
|
||||||
current_lines.append(code_fence)
|
|
||||||
current_len = len(code_fence) + 1
|
|
||||||
|
|
||||||
current_lines.append(line)
|
|
||||||
current_len += line_len
|
|
||||||
|
|
||||||
if current_lines:
|
|
||||||
segments.append('\n'.join(current_lines))
|
|
||||||
|
|
||||||
return segments
|
|
||||||
|
|
||||||
def _split_plain_text(self, text: str, max_length: int) -> list[str]:
|
|
||||||
"""Split a long plain text string (no newlines) at word/space boundaries."""
|
|
||||||
if len(text) <= max_length:
|
|
||||||
return [text]
|
|
||||||
|
|
||||||
segments: list[str] = []
|
|
||||||
remaining = text
|
|
||||||
|
|
||||||
while remaining:
|
|
||||||
if len(remaining) <= max_length:
|
|
||||||
segments.append(remaining)
|
|
||||||
break
|
|
||||||
|
|
||||||
chunk = remaining[:max_length]
|
|
||||||
min_pos = int(max_length * 0.3)
|
|
||||||
|
|
||||||
# Try to find a space to split at
|
|
||||||
pos = chunk.rfind(' ')
|
|
||||||
if pos >= min_pos:
|
|
||||||
split_pos = pos
|
|
||||||
else:
|
|
||||||
# Hard cut as last resort
|
|
||||||
split_pos = max_length
|
|
||||||
|
|
||||||
segments.append(remaining[:split_pos].rstrip())
|
|
||||||
remaining = remaining[split_pos:].lstrip()
|
|
||||||
|
|
||||||
return [s for s in segments if s]
|
|
||||||
@@ -34,6 +34,15 @@ class MonitoringHelper:
|
|||||||
# Check if session exists, if not, record session start
|
# Check if session exists, if not, record session start
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
|
# Get sender name from message event
|
||||||
|
sender_name = None
|
||||||
|
if hasattr(query, 'message_event'):
|
||||||
|
if hasattr(query.message_event, 'sender'):
|
||||||
|
if hasattr(query.message_event.sender, 'nickname'):
|
||||||
|
sender_name = query.message_event.sender.nickname
|
||||||
|
elif hasattr(query.message_event.sender, 'member_name'):
|
||||||
|
sender_name = query.message_event.sender.member_name
|
||||||
|
|
||||||
# Try to record message
|
# Try to record message
|
||||||
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
||||||
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
||||||
@@ -57,6 +66,7 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
|
user_name=sender_name,
|
||||||
runner_name=runner_name,
|
runner_name=runner_name,
|
||||||
variables=None, # Will be updated in record_query_success
|
variables=None, # Will be updated in record_query_success
|
||||||
)
|
)
|
||||||
@@ -80,6 +90,7 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
|
user_name=sender_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
return message_id
|
return message_id
|
||||||
@@ -128,6 +139,15 @@ class MonitoringHelper:
|
|||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
|
# Get sender name from message event
|
||||||
|
sender_name = None
|
||||||
|
if hasattr(query, 'message_event'):
|
||||||
|
if hasattr(query.message_event, 'sender'):
|
||||||
|
if hasattr(query.message_event.sender, 'nickname'):
|
||||||
|
sender_name = query.message_event.sender.nickname
|
||||||
|
elif hasattr(query.message_event.sender, 'member_name'):
|
||||||
|
sender_name = query.message_event.sender.member_name
|
||||||
|
|
||||||
# Extract response content from resp_message_chain
|
# Extract response content from resp_message_chain
|
||||||
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
|
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
|
||||||
# Serialize the last response message chain
|
# Serialize the last response message chain
|
||||||
@@ -162,6 +182,7 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
|
user_name=sender_name,
|
||||||
runner_name=runner_name,
|
runner_name=runner_name,
|
||||||
role='assistant',
|
role='assistant',
|
||||||
)
|
)
|
||||||
@@ -183,6 +204,15 @@ class MonitoringHelper:
|
|||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
|
# Get sender name from message event
|
||||||
|
sender_name = None
|
||||||
|
if hasattr(query, 'message_event'):
|
||||||
|
if hasattr(query.message_event, 'sender'):
|
||||||
|
if hasattr(query.message_event.sender, 'nickname'):
|
||||||
|
sender_name = query.message_event.sender.nickname
|
||||||
|
elif hasattr(query.message_event.sender, 'member_name'):
|
||||||
|
sender_name = query.message_event.sender.member_name
|
||||||
|
|
||||||
# Record error message
|
# Record error message
|
||||||
message_id = await ap.monitoring_service.record_message(
|
message_id = await ap.monitoring_service.record_message(
|
||||||
bot_id=bot_id,
|
bot_id=bot_id,
|
||||||
@@ -197,6 +227,7 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
|
user_name=sender_name,
|
||||||
runner_name=runner_name,
|
runner_name=runner_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ 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.events as platform_events
|
||||||
import langbot_plugin.api.entities.events as events
|
import langbot_plugin.api.entities.events as events
|
||||||
from ..utils import importutil
|
from ..utils import importutil
|
||||||
|
from .config_coercion import coerce_pipeline_config
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
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.pipeline.query as pipeline_query
|
||||||
@@ -420,6 +421,14 @@ class PipelineManager:
|
|||||||
elif isinstance(pipeline_entity, dict):
|
elif isinstance(pipeline_entity, dict):
|
||||||
pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)
|
pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)
|
||||||
|
|
||||||
|
coerce_pipeline_config(
|
||||||
|
pipeline_entity.config,
|
||||||
|
getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),
|
||||||
|
getattr(self.ap, 'pipeline_config_meta_safety', {'name': 'safety', 'stages': []}),
|
||||||
|
getattr(self.ap, 'pipeline_config_meta_ai', {'name': 'ai', 'stages': []}),
|
||||||
|
getattr(self.ap, 'pipeline_config_meta_output', {'name': 'output', 'stages': []}),
|
||||||
|
)
|
||||||
|
|
||||||
# initialize stage containers according to pipeline_entity.stages
|
# initialize stage containers according to pipeline_entity.stages
|
||||||
stage_containers: list[StageInstContainer] = []
|
stage_containers: list[StageInstContainer] = []
|
||||||
for stage_name in pipeline_entity.stages:
|
for stage_name in pipeline_entity.stages:
|
||||||
|
|||||||
@@ -36,17 +36,36 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
session = await self.ap.sess_mgr.get_session(query)
|
session = await self.ap.sess_mgr.get_session(query)
|
||||||
|
|
||||||
# When not local-agent, llm_model is None
|
# When not local-agent, llm_model is None
|
||||||
try:
|
llm_model = None
|
||||||
llm_model = (
|
if selected_runner == 'local-agent':
|
||||||
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model'])
|
# Read model config — new format is { primary: str, fallbacks: [str] },
|
||||||
if selected_runner == 'local-agent'
|
# but handle legacy plain string for backward compatibility
|
||||||
else None
|
model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
|
||||||
)
|
if isinstance(model_config, str):
|
||||||
except ValueError:
|
# Legacy format: plain UUID string
|
||||||
self.ap.logger.warning(
|
primary_uuid = model_config
|
||||||
f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured'
|
fallback_uuids = []
|
||||||
)
|
else:
|
||||||
llm_model = None
|
primary_uuid = model_config.get('primary', '')
|
||||||
|
fallback_uuids = model_config.get('fallbacks', [])
|
||||||
|
|
||||||
|
if primary_uuid:
|
||||||
|
try:
|
||||||
|
llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
|
||||||
|
except ValueError:
|
||||||
|
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
|
||||||
|
|
||||||
|
# Resolve fallback model UUIDs
|
||||||
|
if fallback_uuids:
|
||||||
|
valid_fallbacks = []
|
||||||
|
for fb_uuid in fallback_uuids:
|
||||||
|
try:
|
||||||
|
await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
|
||||||
|
valid_fallbacks.append(fb_uuid)
|
||||||
|
except ValueError:
|
||||||
|
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
|
||||||
|
if valid_fallbacks:
|
||||||
|
query.variables['_fallback_model_uuids'] = valid_fallbacks
|
||||||
|
|
||||||
conversation = await self.ap.sess_mgr.get_conversation(
|
conversation = await self.ap.sess_mgr.get_conversation(
|
||||||
query,
|
query,
|
||||||
@@ -61,20 +80,28 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query.prompt = conversation.prompt.copy()
|
query.prompt = conversation.prompt.copy()
|
||||||
query.messages = conversation.messages.copy()
|
query.messages = conversation.messages.copy()
|
||||||
|
|
||||||
if selected_runner == 'local-agent' and llm_model:
|
if selected_runner == 'local-agent':
|
||||||
query.use_funcs = []
|
query.use_funcs = []
|
||||||
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
if llm_model:
|
||||||
|
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
||||||
|
|
||||||
if llm_model.model_entity.abilities.__contains__('func_call'):
|
if llm_model.model_entity.abilities.__contains__('func_call'):
|
||||||
# Get bound plugins and MCP servers for filtering tools
|
# Get bound plugins and MCP servers for filtering tools
|
||||||
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||||
|
|
||||||
|
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||||
|
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
||||||
|
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
|
||||||
|
|
||||||
|
# If primary model doesn't support func_call but fallback models exist,
|
||||||
|
# load tools anyway since fallback models may support them
|
||||||
|
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
|
||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||||
|
|
||||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
|
||||||
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
|
||||||
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
|
|
||||||
|
|
||||||
sender_name = ''
|
sender_name = ''
|
||||||
|
|
||||||
if isinstance(query.message_event, platform_events.GroupMessage):
|
if isinstance(query.message_event, platform_events.GroupMessage):
|
||||||
@@ -149,6 +176,16 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query.variables['user_message_text'] = plain_text
|
query.variables['user_message_text'] = plain_text
|
||||||
|
|
||||||
query.user_message = provider_message.Message(role='user', content=content_list)
|
query.user_message = provider_message.Message(role='user', content=content_list)
|
||||||
|
|
||||||
|
# Extract knowledge base UUIDs into query variables so plugins can modify them
|
||||||
|
# during PromptPreProcessing before the runner performs retrieval.
|
||||||
|
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
|
||||||
|
if not kb_uuids:
|
||||||
|
old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '')
|
||||||
|
if old_kb_uuid and old_kb_uuid != '__none__':
|
||||||
|
kb_uuids = [old_kb_uuid]
|
||||||
|
query.variables['_knowledge_base_uuids'] = list(kb_uuids)
|
||||||
|
|
||||||
# =========== 触发事件 PromptPreProcessing
|
# =========== 触发事件 PromptPreProcessing
|
||||||
|
|
||||||
event = events.PromptPreProcessing(
|
event = events.PromptPreProcessing(
|
||||||
|
|||||||
@@ -149,12 +149,19 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']
|
exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
|
||||||
|
|
||||||
|
if exception_handling == 'show-error':
|
||||||
|
user_notice = f'{e}'
|
||||||
|
elif exception_handling == 'show-hint':
|
||||||
|
user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.')
|
||||||
|
else: # hide
|
||||||
|
user_notice = None
|
||||||
|
|
||||||
yield entities.StageProcessResult(
|
yield entities.StageProcessResult(
|
||||||
result_type=entities.ResultType.INTERRUPT,
|
result_type=entities.ResultType.INTERRUPT,
|
||||||
new_query=query,
|
new_query=query,
|
||||||
user_notice='请求失败' if hide_exception_info else f'{e}',
|
user_notice=user_notice,
|
||||||
error_notice=f'{e}',
|
error_notice=f'{e}',
|
||||||
debug_notice=traceback.format_exc(),
|
debug_notice=traceback.format_exc(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -55,15 +55,4 @@ class SendResponseBackStage(stage.PipelineStage):
|
|||||||
quote_origin=quote_origin,
|
quote_origin=quote_origin,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Send extra chains produced by long text split strategy
|
|
||||||
extra_chains = query.get_variable('_longtext_split_extra_chains')
|
|
||||||
if extra_chains:
|
|
||||||
for chain in extra_chains:
|
|
||||||
await query.adapter.reply_message(
|
|
||||||
message_source=query.message_event,
|
|
||||||
message=chain,
|
|
||||||
quote_origin=False,
|
|
||||||
)
|
|
||||||
query.set_variable('_longtext_split_extra_chains', None)
|
|
||||||
|
|
||||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from ..core import app, entities as core_entities, taskmgr
|
|||||||
from ..discover import engine
|
from ..discover import engine
|
||||||
|
|
||||||
from ..entity.persistence import bot as persistence_bot
|
from ..entity.persistence import bot as persistence_bot
|
||||||
|
from ..entity.persistence import pipeline as persistence_pipeline
|
||||||
|
|
||||||
from ..entity.errors import platform as platform_errors
|
from ..entity.errors import platform as platform_errors
|
||||||
|
|
||||||
@@ -141,6 +142,50 @@ class RuntimeBot:
|
|||||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
|
||||||
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
|
||||||
|
|
||||||
|
# Register feedback listener (only effective on adapters that support it)
|
||||||
|
async def on_feedback(
|
||||||
|
event: platform_events.FeedbackEvent,
|
||||||
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
# Resolve pipeline name
|
||||||
|
pipeline_name = ''
|
||||||
|
if self.bot_entity.use_pipeline_uuid:
|
||||||
|
try:
|
||||||
|
pipeline_result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_pipeline.LegacyPipeline.name).where(
|
||||||
|
persistence_pipeline.LegacyPipeline.uuid == self.bot_entity.use_pipeline_uuid
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pipeline_row = pipeline_result.first()
|
||||||
|
if pipeline_row:
|
||||||
|
pipeline_name = pipeline_row[0]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await self.ap.monitoring_service.record_feedback(
|
||||||
|
feedback_id=event.feedback_id,
|
||||||
|
feedback_type=event.feedback_type,
|
||||||
|
feedback_content=event.feedback_content,
|
||||||
|
inaccurate_reasons=event.inaccurate_reasons,
|
||||||
|
bot_id=self.bot_entity.uuid,
|
||||||
|
bot_name=self.bot_entity.name,
|
||||||
|
pipeline_id=self.bot_entity.use_pipeline_uuid or '',
|
||||||
|
pipeline_name=pipeline_name,
|
||||||
|
session_id=event.session_id,
|
||||||
|
message_id=event.message_id,
|
||||||
|
stream_id=event.stream_id,
|
||||||
|
user_id=event.user_id,
|
||||||
|
platform=adapter.__class__.__name__,
|
||||||
|
)
|
||||||
|
await self.logger.info(
|
||||||
|
f'Recorded feedback: feedback_id={event.feedback_id}, type={event.feedback_type}'
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Failed to record feedback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
self.adapter.register_listener(platform_events.FeedbackEvent, on_feedback)
|
||||||
|
|
||||||
async def run(self):
|
async def run(self):
|
||||||
async def exception_wrapper():
|
async def exception_wrapper():
|
||||||
try:
|
try:
|
||||||
@@ -282,6 +327,8 @@ class PlatformManager:
|
|||||||
return runtime_bot
|
return runtime_bot
|
||||||
|
|
||||||
async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None:
|
async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None:
|
||||||
|
if self.websocket_proxy_bot and self.websocket_proxy_bot.bot_entity.uuid == bot_uuid:
|
||||||
|
return self.websocket_proxy_bot
|
||||||
for bot in self.bots:
|
for bot in self.bots:
|
||||||
if bot.bot_entity.uuid == bot_uuid:
|
if bot.bot_entity.uuid == bot_uuid:
|
||||||
return bot
|
return bot
|
||||||
|
|||||||
@@ -5,19 +5,29 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: OneBot v11
|
en_US: OneBot v11
|
||||||
zh_Hans: OneBot v11
|
zh_Hans: OneBot v11
|
||||||
|
zh_Hant: OneBot v11
|
||||||
description:
|
description:
|
||||||
en_US: OneBot v11 Adapter
|
en_US: OneBot v11 Adapter, used for QQ bots
|
||||||
zh_Hans: OneBot v11 适配器,请查看文档了解使用方式
|
zh_Hans: OneBot v11 适配器,用于接入 QQ 机器人协议端,请查看文档了解使用方式
|
||||||
|
zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式
|
||||||
icon: onebot.png
|
icon: onebot.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- protocol
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/aiocqhttp
|
||||||
|
en: https://link.langbot.app/en/platforms/aiocqhttp
|
||||||
|
ja: https://link.langbot.app/ja/platforms/aiocqhttp
|
||||||
config:
|
config:
|
||||||
- name: host
|
- name: host
|
||||||
label:
|
label:
|
||||||
en_US: Host
|
en_US: Host
|
||||||
zh_Hans: 主机
|
zh_Hans: 主机
|
||||||
|
zh_Hant: 主機
|
||||||
description:
|
description:
|
||||||
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
|
en_US: The host that OneBot v11 listens on for reverse WebSocket connections. Unless you know what you're doing, use 0.0.0.0
|
||||||
zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
||||||
|
zh_Hant: OneBot v11 監聽的反向 WS 主機,除非你知道自己在做什麼,否則請填 0.0.0.0
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: 0.0.0.0
|
default: 0.0.0.0
|
||||||
@@ -25,9 +35,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Port
|
en_US: Port
|
||||||
zh_Hans: 端口
|
zh_Hans: 端口
|
||||||
|
zh_Hant: 連接埠
|
||||||
description:
|
description:
|
||||||
en_US: Port
|
en_US: Port
|
||||||
zh_Hans: 监听的端口
|
zh_Hans: 监听的端口
|
||||||
|
zh_Hant: 監聽的連接埠
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 2280
|
default: 2280
|
||||||
@@ -35,9 +47,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Access Token
|
en_US: Access Token
|
||||||
zh_Hans: 访问令牌
|
zh_Hans: 访问令牌
|
||||||
|
zh_Hant: 存取令牌
|
||||||
description:
|
description:
|
||||||
en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it
|
en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it
|
||||||
zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填
|
zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填
|
||||||
|
zh_Hant: 自訂的與協定端的連線令牌,若協定端未設定,則不填
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,16 +5,25 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: DingTalk
|
en_US: DingTalk
|
||||||
zh_Hans: 钉钉
|
zh_Hans: 钉钉
|
||||||
|
zh_Hant: 釘釘
|
||||||
description:
|
description:
|
||||||
en_US: DingTalk Adapter
|
en_US: DingTalk Adapter
|
||||||
zh_Hans: 钉钉适配器,请查看文档了解使用方式
|
zh_Hans: 钉钉适配器,请查看文档了解使用方式
|
||||||
|
zh_Hant: 釘釘適配器,請查看文件了解使用方式
|
||||||
icon: dingtalk.svg
|
icon: dingtalk.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/dingtalk
|
||||||
|
en: https://link.langbot.app/en/platforms/dingtalk
|
||||||
|
ja: https://link.langbot.app/ja/platforms/dingtalk
|
||||||
config:
|
config:
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
en_US: Client ID
|
en_US: Client ID
|
||||||
zh_Hans: 客户端ID
|
zh_Hans: 客户端ID
|
||||||
|
zh_Hant: 用戶端ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +31,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Client Secret
|
en_US: Client Secret
|
||||||
zh_Hans: 客户端密钥
|
zh_Hans: 客户端密钥
|
||||||
|
zh_Hant: 用戶端密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +39,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Robot Code
|
en_US: Robot Code
|
||||||
zh_Hans: 机器人代码
|
zh_Hans: 机器人代码
|
||||||
|
zh_Hant: 機器人代碼
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -36,6 +47,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Robot Name
|
en_US: Robot Name
|
||||||
zh_Hans: 机器人名称
|
zh_Hans: 机器人名称
|
||||||
|
zh_Hant: 機器人名稱
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -43,6 +55,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Markdown Card
|
en_US: Markdown Card
|
||||||
zh_Hans: 是否使用 Markdown 卡片
|
zh_Hans: 是否使用 Markdown 卡片
|
||||||
|
zh_Hant: 是否使用 Markdown 卡片
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
@@ -50,9 +63,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Enable Stream Reply Mode
|
en_US: Enable Stream Reply Mode
|
||||||
zh_Hans: 启用钉钉卡片流式回复模式
|
zh_Hans: 启用钉钉卡片流式回复模式
|
||||||
|
zh_Hant: 啟用釘釘卡片串流回覆模式
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||||
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
|
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
|
||||||
|
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
@@ -60,6 +75,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Card Auto Layout
|
en_US: Card Auto Layout
|
||||||
zh_Hans: 卡片宽屏自动布局
|
zh_Hans: 卡片宽屏自动布局
|
||||||
|
zh_Hant: 卡片寬螢幕自動佈局
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: false
|
default: false
|
||||||
@@ -67,6 +83,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: card template id
|
en_US: card template id
|
||||||
zh_Hans: 卡片模板ID
|
zh_Hans: 卡片模板ID
|
||||||
|
zh_Hant: 卡片範本ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "填写你的卡片template_id"
|
default: "填写你的卡片template_id"
|
||||||
|
|||||||
@@ -5,16 +5,38 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Discord
|
en_US: Discord
|
||||||
zh_Hans: Discord
|
zh_Hans: Discord
|
||||||
|
zh_Hant: Discord
|
||||||
|
ja_JP: Discord
|
||||||
|
th_TH: Discord
|
||||||
|
vi_VN: Discord
|
||||||
|
es_ES: Discord
|
||||||
description:
|
description:
|
||||||
en_US: Discord Adapter
|
en_US: Discord Adapter
|
||||||
zh_Hans: Discord 适配器,请查看文档了解使用方式
|
zh_Hans: Discord 适配器,需要可连接 Discord 服务器的网络环境
|
||||||
|
zh_Hant: Discord 適配器,需要可連線 Discord 伺服器的網路環境
|
||||||
|
ja_JP: Discord アダプター、Discord サーバーに接続可能なネットワーク環境が必要です
|
||||||
|
th_TH: อะแดปเตอร์ Discord ต้องการสภาพแวดล้อมเครือข่ายที่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ Discord ได้
|
||||||
|
vi_VN: Bộ điều hợp Discord, cần môi trường mạng có thể kết nối với máy chủ Discord
|
||||||
|
es_ES: Adaptador de Discord, requiere un entorno de red con acceso al servidor de Discord
|
||||||
icon: discord.svg
|
icon: discord.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/discord
|
||||||
|
en: https://link.langbot.app/en/platforms/discord
|
||||||
|
ja: https://link.langbot.app/ja/platforms/discord
|
||||||
config:
|
config:
|
||||||
- name: client_id
|
- name: client_id
|
||||||
label:
|
label:
|
||||||
en_US: Client ID
|
en_US: Client ID
|
||||||
zh_Hans: 客户端ID
|
zh_Hans: 客户端ID
|
||||||
|
zh_Hant: 用戶端ID
|
||||||
|
ja_JP: クライアント ID
|
||||||
|
th_TH: รหัสไคลเอนต์
|
||||||
|
vi_VN: ID khách hàng
|
||||||
|
es_ES: ID de cliente
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +44,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
|
ja_JP: トークン
|
||||||
|
th_TH: โทเค็น
|
||||||
|
vi_VN: Mã thông báo
|
||||||
|
es_ES: Token
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,16 +5,25 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: KOOK
|
en_US: KOOK
|
||||||
zh_Hans: KOOK
|
zh_Hans: KOOK
|
||||||
|
zh_Hant: KOOK
|
||||||
description:
|
description:
|
||||||
en_US: KOOK Adapter (formerly KaiHeiLa)
|
en_US: KOOK Adapter (formerly KaiHeiLa)
|
||||||
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
|
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
|
||||||
|
zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息
|
||||||
icon: kook.png
|
icon: kook.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/kook
|
||||||
|
en: https://link.langbot.app/en/platforms/kook
|
||||||
|
ja: https://link.langbot.app/ja/platforms/kook
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Bot Token
|
en_US: Bot Token
|
||||||
zh_Hans: 机器人令牌
|
zh_Hans: 机器人令牌
|
||||||
|
zh_Hant: 機器人令牌
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -575,6 +575,127 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
|
|
||||||
|
|
||||||
class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
|
_processed_thread_quote_cache: typing.ClassVar[dict[str, float]] = {}
|
||||||
|
_processed_thread_quote_cache_max_size: typing.ClassVar[int] = 4096
|
||||||
|
_processed_thread_quote_cache_ttl_seconds: typing.ClassVar[int] = 86400
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _prune_processed_thread_quote_cache(cls, now: typing.Optional[float] = None) -> None:
|
||||||
|
if now is None:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
expire_before = now - cls._processed_thread_quote_cache_ttl_seconds
|
||||||
|
while cls._processed_thread_quote_cache:
|
||||||
|
oldest_key, oldest_ts = next(iter(cls._processed_thread_quote_cache.items()))
|
||||||
|
if oldest_ts >= expire_before:
|
||||||
|
break
|
||||||
|
cls._processed_thread_quote_cache.pop(oldest_key, None)
|
||||||
|
|
||||||
|
while len(cls._processed_thread_quote_cache) > cls._processed_thread_quote_cache_max_size:
|
||||||
|
oldest_key = next(iter(cls._processed_thread_quote_cache))
|
||||||
|
cls._processed_thread_quote_cache.pop(oldest_key, None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _mark_thread_quote_processed(cls, thread_id: str) -> None:
|
||||||
|
now = time.time()
|
||||||
|
cls._prune_processed_thread_quote_cache(now)
|
||||||
|
cls._processed_thread_quote_cache[thread_id] = now
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extract_quote_message_id(cls, message: EventMessage) -> typing.Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract the message ID to quote from the given message.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- First thread reply in a topic: return parent_id and mark topic as processed
|
||||||
|
- Follow-up thread replies in the same topic: return None
|
||||||
|
- Non-thread message: return parent_id if valid (non-empty, different from message_id)
|
||||||
|
|
||||||
|
Thread reply state is kept in a bounded TTL cache to avoid unbounded memory growth.
|
||||||
|
"""
|
||||||
|
parent_id = getattr(message, 'parent_id', None)
|
||||||
|
if not parent_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message_id = getattr(message, 'message_id', None)
|
||||||
|
if parent_id == message_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
thread_id = getattr(message, 'thread_id', None)
|
||||||
|
if thread_id:
|
||||||
|
cls._prune_processed_thread_quote_cache()
|
||||||
|
if thread_id in cls._processed_thread_quote_cache:
|
||||||
|
return None
|
||||||
|
cls._mark_thread_quote_processed(thread_id)
|
||||||
|
|
||||||
|
return parent_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_event_message_from_message_item(message_item: Message) -> typing.Optional[EventMessage]:
|
||||||
|
"""
|
||||||
|
Build EventMessage from SDK typed Message item.
|
||||||
|
|
||||||
|
Returns None if body or content is missing.
|
||||||
|
"""
|
||||||
|
body = getattr(message_item, 'body', None)
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = getattr(body, 'content', None)
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
event_data = {
|
||||||
|
'message_id': message_item.message_id,
|
||||||
|
'message_type': message_item.msg_type,
|
||||||
|
'content': content,
|
||||||
|
'create_time': message_item.create_time,
|
||||||
|
'mentions': getattr(message_item, 'mentions', []) or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preserve thread-related fields
|
||||||
|
if hasattr(message_item, 'parent_id') and message_item.parent_id:
|
||||||
|
event_data['parent_id'] = message_item.parent_id
|
||||||
|
if hasattr(message_item, 'root_id') and message_item.root_id:
|
||||||
|
event_data['root_id'] = message_item.root_id
|
||||||
|
if hasattr(message_item, 'thread_id') and message_item.thread_id:
|
||||||
|
event_data['thread_id'] = message_item.thread_id
|
||||||
|
if hasattr(message_item, 'chat_id') and message_item.chat_id:
|
||||||
|
event_data['chat_id'] = message_item.chat_id
|
||||||
|
|
||||||
|
return EventMessage(event_data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _fetch_quoted_message(
|
||||||
|
quote_message_id: str,
|
||||||
|
api_client: lark_oapi.Client,
|
||||||
|
) -> typing.Optional[platform_message.MessageChain]:
|
||||||
|
"""
|
||||||
|
Fetch the quoted message and convert to MessageChain.
|
||||||
|
|
||||||
|
Returns None if:
|
||||||
|
- API call fails
|
||||||
|
- Response items is empty
|
||||||
|
- Message item normalization fails
|
||||||
|
"""
|
||||||
|
request = GetMessageRequest.builder().message_id(quote_message_id).build()
|
||||||
|
response = await api_client.im.v1.message.aget(request)
|
||||||
|
|
||||||
|
if not response.success():
|
||||||
|
return None
|
||||||
|
|
||||||
|
items = getattr(response.data, 'items', None)
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message_item = items[0]
|
||||||
|
event_message = LarkEventConverter._build_event_message_from_message_item(message_item)
|
||||||
|
if event_message is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
quote_chain = await LarkMessageConverter.target2yiri(event_message, api_client)
|
||||||
|
return quote_chain
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(
|
async def yiri2target(
|
||||||
event: platform_events.MessageEvent,
|
event: platform_events.MessageEvent,
|
||||||
@@ -587,6 +708,23 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
) -> platform_events.Event:
|
) -> platform_events.Event:
|
||||||
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
|
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
|
||||||
|
|
||||||
|
# Check for quote/reply message
|
||||||
|
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
|
||||||
|
if quote_message_id:
|
||||||
|
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
|
||||||
|
if quote_chain:
|
||||||
|
# Filter out Source component from quoted chain, keep only content
|
||||||
|
quote_origin = platform_message.MessageChain(
|
||||||
|
[comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
|
||||||
|
)
|
||||||
|
if quote_origin:
|
||||||
|
message_chain.append(
|
||||||
|
platform_message.Quote(
|
||||||
|
message_id=quote_message_id,
|
||||||
|
origin=quote_origin,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if event.event.message.chat_type == 'p2p':
|
if event.event.message.chat_type == 'p2p':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
@@ -770,6 +908,32 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.request_tenant_access_token(tenant_key)
|
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
|
return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None
|
||||||
|
|
||||||
|
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
|
||||||
|
"""
|
||||||
|
Get topic-scoped launcher_id for thread-aware session isolation.
|
||||||
|
|
||||||
|
For group thread messages, returns "{group_id}_{thread_id}"
|
||||||
|
to ensure conversation context stays stable per topic.
|
||||||
|
|
||||||
|
Returns None for non-thread messages or P2P messages.
|
||||||
|
"""
|
||||||
|
source_event = getattr(event.source_platform_object, 'event', None)
|
||||||
|
if not source_event:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message = getattr(source_event, 'message', None)
|
||||||
|
if not message:
|
||||||
|
return None
|
||||||
|
|
||||||
|
thread_id = getattr(message, 'thread_id', None)
|
||||||
|
if not thread_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(event, platform_events.GroupMessage):
|
||||||
|
return f'{event.group.id}_{thread_id}'
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def build_api_client(self, config):
|
def build_api_client(self, config):
|
||||||
app_id = config['app_id']
|
app_id = config['app_id']
|
||||||
app_secret = config['app_secret']
|
app_secret = config['app_secret']
|
||||||
|
|||||||
@@ -5,16 +5,30 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Lark
|
en_US: Lark
|
||||||
zh_Hans: 飞书
|
zh_Hans: 飞书
|
||||||
|
zh_Hant: 飛書
|
||||||
|
ja_JP: Lark
|
||||||
description:
|
description:
|
||||||
en_US: Lark Adapter
|
en_US: Lark Adapter, supports both long connection and Webhook modes. Please refer to the documentation for usage details.
|
||||||
zh_Hans: 飞书适配器,请查看文档了解使用方式
|
zh_Hans: 飞书适配器,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
|
||||||
|
zh_Hant: 飛書適配器,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
|
||||||
|
ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。
|
||||||
icon: lark.svg
|
icon: lark.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- china
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/lark
|
||||||
|
en: https://link.langbot.app/en/platforms/lark
|
||||||
|
ja: https://link.langbot.app/ja/platforms/lark
|
||||||
config:
|
config:
|
||||||
- name: app_id
|
- name: app_id
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
zh_Hans: 应用ID
|
zh_Hans: 应用ID
|
||||||
|
zh_Hant: 應用ID
|
||||||
|
ja_JP: アプリ ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +36,8 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: App Secret
|
en_US: App Secret
|
||||||
zh_Hans: 应用密钥
|
zh_Hans: 应用密钥
|
||||||
|
zh_Hant: 應用密鑰
|
||||||
|
ja_JP: アプリシークレット
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,9 +45,13 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Bot Name
|
en_US: Bot Name
|
||||||
zh_Hans: 机器人名称
|
zh_Hans: 机器人名称
|
||||||
|
zh_Hant: 機器人名稱
|
||||||
|
ja_JP: ボット名
|
||||||
description:
|
description:
|
||||||
en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group
|
en_US: Must be the same as the name of the bot in Lark, otherwise the bot will not be able to receive messages in the group
|
||||||
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
|
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
|
||||||
|
zh_Hant: 必須與飛書機器人名稱一致,否則機器人將無法在群組內正常接收訊息
|
||||||
|
ja_JP: Lark のボット名と一致する必要があります。一致しない場合、グループ内でメッセージを受信できません
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -39,29 +59,63 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Enable Webhook Mode
|
en_US: Enable Webhook Mode
|
||||||
zh_Hans: 启用Webhook模式
|
zh_Hans: 启用Webhook模式
|
||||||
|
zh_Hant: 啟用 Webhook 模式
|
||||||
|
ja_JP: Webhook モードを有効化
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
||||||
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
||||||
|
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式
|
||||||
|
ja_JP: 有効にすると、ボットは Webhook モードでメッセージを受信します。無効の場合は WS 長期接続モードを使用します
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
ja_JP: Webhook コールバック URL
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your Lark app's webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到飞书应用的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到飛書應用的 Webhook 設定中
|
||||||
|
ja_JP: この URL をコピーして Lark アプリの Webhook 設定に貼り付けてください
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
show_if:
|
||||||
|
field: enable-webhook
|
||||||
|
operator: eq
|
||||||
|
value: true
|
||||||
- name: encrypt-key
|
- name: encrypt-key
|
||||||
label:
|
label:
|
||||||
en_US: Encrypt Key
|
en_US: Encrypt Key
|
||||||
zh_Hans: 加密密钥
|
zh_Hans: 加密密钥
|
||||||
|
zh_Hant: 加密密鑰
|
||||||
|
ja_JP: 暗号化キー
|
||||||
description:
|
description:
|
||||||
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
||||||
zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥
|
zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥
|
||||||
|
zh_Hant: 僅在啟用 Webhook 模式時有效,請填寫加密密鑰
|
||||||
|
ja_JP: Webhook モードが有効な場合にのみ有効です。暗号化キーを入力してください
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
show_if:
|
||||||
|
field: enable-webhook
|
||||||
|
operator: eq
|
||||||
|
value: true
|
||||||
- name: enable-stream-reply
|
- name: enable-stream-reply
|
||||||
label:
|
label:
|
||||||
en_US: Enable Stream Reply Mode
|
en_US: Enable Stream Reply Mode
|
||||||
zh_Hans: 启用飞书流式回复模式
|
zh_Hans: 启用飞书流式回复模式
|
||||||
|
zh_Hant: 啟用飛書串流回覆模式
|
||||||
|
ja_JP: ストリーミング返信モードを有効化
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||||
zh_Hans: 如果启用,将使用飞书流式方式来回复内容
|
zh_Hans: 如果启用,将使用飞书流式方式来回复内容
|
||||||
|
zh_Hant: 如果啟用,將使用飛書串流方式來回覆內容
|
||||||
|
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
@@ -69,28 +123,40 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: App Type
|
en_US: App Type
|
||||||
zh_Hans: 应用类型
|
zh_Hans: 应用类型
|
||||||
|
zh_Hant: 應用類型
|
||||||
|
ja_JP: アプリタイプ
|
||||||
description:
|
description:
|
||||||
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
|
zh_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||||
|
ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください
|
||||||
type: select
|
type: select
|
||||||
options:
|
options:
|
||||||
- name: self
|
- name: self
|
||||||
label:
|
label:
|
||||||
en_US: Self-built Application
|
en_US: Self-built Application
|
||||||
zh_Hans: 自建应用
|
zh_Hans: 自建应用
|
||||||
|
zh_Hant: 自建應用
|
||||||
|
ja_JP: カスタムアプリ
|
||||||
- name: isv
|
- name: isv
|
||||||
label:
|
label:
|
||||||
en_US: Store Application
|
en_US: Store Application
|
||||||
zh_Hans: 商店应用
|
zh_Hans: 商店应用
|
||||||
|
zh_Hant: 商店應用
|
||||||
|
ja_JP: ストアアプリ
|
||||||
required: false
|
required: false
|
||||||
default: self
|
default: self
|
||||||
- name: bot_added_welcome
|
- name: bot_added_welcome
|
||||||
label:
|
label:
|
||||||
en_US: Bot Welcome Message
|
en_US: Bot Welcome Message
|
||||||
zh_Hans: 机器人进群欢迎语
|
zh_Hans: 机器人进群欢迎语
|
||||||
|
zh_Hant: 機器人進群歡迎語
|
||||||
|
ja_JP: ボット参加時のウェルカムメッセージ
|
||||||
description:
|
description:
|
||||||
en_US: Welcome message when the bot is added to a group, supports Markdown format
|
en_US: Welcome message when the bot is added to a group, supports Markdown format
|
||||||
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
|
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
|
||||||
|
zh_Hant: 機器人進群歡迎語,支援 Markdown 格式
|
||||||
|
ja_JP: ボットがグループに追加された際のウェルカムメッセージ。Markdown 形式に対応しています
|
||||||
type: text
|
type: text
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,20 +5,56 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: LINE
|
en_US: LINE
|
||||||
zh_Hans: LINE
|
zh_Hans: LINE
|
||||||
|
zh_Hant: LINE
|
||||||
|
th_TH: LINE
|
||||||
|
vi_VN: LINE
|
||||||
|
es_ES: LINE
|
||||||
description:
|
description:
|
||||||
en_US: LINE Adapter
|
en_US: LINE Adapter, requires a public URL to receive LINE message pushes, please refer to the documentation for usage details
|
||||||
zh_Hans: LINE适配器,请查看文档了解使用方式
|
zh_Hans: LINE适配器,需要公网地址以接收 LINE 消息推送,请查看文档了解使用方式
|
||||||
ja_JP: LINEアダプター、ドキュメントを参照してください
|
zh_Hant: LINE 適配器,需要公網地址以接收 LINE 訊息推送,請查看文件了解使用方式
|
||||||
zh_Hant: LINE適配器,請查看文檔了解使用方式
|
ja_JP: LINEアダプター、LINEのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。
|
||||||
|
th_TH: อะแดปเตอร์ LINE ต้องการ URL สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก LINE โปรดดูเอกสารประกอบสำหรับรายละเอียดการใช้งาน
|
||||||
|
vi_VN: Bộ điều hợp LINE, cần URL công cộng để nhận thông báo tin nhắn LINE, vui lòng xem tài liệu để biết chi tiết cách sử dụng
|
||||||
|
es_ES: Adaptador de LINE, requiere una URL pública para recibir notificaciones de mensajes de LINE, consulte la documentación para obtener detalles de uso
|
||||||
icon: line.png
|
icon: line.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/line
|
||||||
|
en: https://link.langbot.app/en/platforms/line
|
||||||
|
ja: https://link.langbot.app/ja/platforms/line
|
||||||
config:
|
config:
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
ja_JP: Webhook コールバック URL
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
th_TH: URL การเรียกกลับ Webhook
|
||||||
|
vi_VN: URL gọi lại Webhook
|
||||||
|
es_ES: URL de devolución de llamada Webhook
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your LINE channel's webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到 LINE 频道的 Webhook 配置中
|
||||||
|
ja_JP: この URL をコピーして LINE チャンネルの Webhook 設定に貼り付けてください
|
||||||
|
zh_Hant: 複製此地址並貼到 LINE 頻道的 Webhook 設定中
|
||||||
|
th_TH: คัดลอก URL นี้แล้ววางในการตั้งค่า Webhook ของช่อง LINE ของคุณ
|
||||||
|
vi_VN: Sao chép URL này và dán vào cấu hình webhook của kênh LINE của bạn
|
||||||
|
es_ES: Copie esta URL y péguela en la configuración de webhook de su canal LINE
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: channel_access_token
|
- name: channel_access_token
|
||||||
label:
|
label:
|
||||||
en_US: Channel access token
|
en_US: Channel access token
|
||||||
zh_Hans: 频道访问令牌
|
zh_Hans: 频道访问令牌
|
||||||
ja_JP: チャンネルアクセストークン
|
ja_JP: チャンネルアクセストークン
|
||||||
zh_Hant: 頻道訪問令牌
|
zh_Hant: 頻道存取令牌
|
||||||
|
th_TH: โทเค็นการเข้าถึงช่อง
|
||||||
|
vi_VN: Mã truy cập kênh
|
||||||
|
es_ES: Token de acceso del canal
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -27,12 +63,18 @@ spec:
|
|||||||
en_US: Channel secret
|
en_US: Channel secret
|
||||||
zh_Hans: 消息密钥
|
zh_Hans: 消息密钥
|
||||||
ja_JP: チャンネルシークレット
|
ja_JP: チャンネルシークレット
|
||||||
zh_Hant: 消息密钥
|
zh_Hant: 訊息密鑰
|
||||||
|
th_TH: รหัสลับช่อง
|
||||||
|
vi_VN: Khóa bí mật kênh
|
||||||
|
es_ES: Secreto del canal
|
||||||
description:
|
description:
|
||||||
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
||||||
zh_Hans: 请填写加密密钥
|
zh_Hans: 请填写加密密钥
|
||||||
ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください
|
ja_JP: Webhookモードが有効な場合にのみ、暗号化キーを入力してください
|
||||||
zh_Hant: 請填寫加密密钥
|
zh_Hant: 請填寫加密密鑰
|
||||||
|
th_TH: กรุณากรอกคีย์เข้ารหัส
|
||||||
|
vi_VN: Vui lòng điền khóa mã hóa
|
||||||
|
es_ES: Por favor, introduzca la clave de cifrado
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,23 +5,44 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Official Account
|
en_US: Official Account
|
||||||
zh_Hans: 微信公众号
|
zh_Hans: 微信公众号
|
||||||
|
zh_Hant: 微信公眾號
|
||||||
description:
|
description:
|
||||||
en_US: Official Account Adapter
|
en_US: Official Account Adapter
|
||||||
zh_Hans: 微信公众号适配器,请查看文档了解使用方式
|
zh_Hans: 微信公众号适配器,需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||||
|
zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||||
icon: officialaccount.png
|
icon: officialaccount.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/officialaccount
|
||||||
|
en: https://link.langbot.app/en/platforms/officialaccount
|
||||||
|
ja: https://link.langbot.app/ja/platforms/officialaccount
|
||||||
config:
|
config:
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your Official Account webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到微信公众号的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到微信公眾號的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
type: string
|
zh_Hant: 令牌
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
- name: EncodingAESKey
|
- name: EncodingAESKey
|
||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥
|
zh_Hans: 消息加解密密钥
|
||||||
|
zh_Hant: 訊息加解密密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +50,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
zh_Hans: 应用ID
|
zh_Hans: 应用ID
|
||||||
|
zh_Hant: 應用ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -36,6 +58,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: App Secret
|
en_US: App Secret
|
||||||
zh_Hans: 应用密钥
|
zh_Hans: 应用密钥
|
||||||
|
zh_Hant: 應用密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -43,6 +66,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Mode
|
en_US: Mode
|
||||||
zh_Hans: 接入模式
|
zh_Hans: 接入模式
|
||||||
|
zh_Hant: 接入模式
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "drop"
|
default: "drop"
|
||||||
@@ -50,6 +74,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Loading Message
|
en_US: Loading Message
|
||||||
zh_Hans: 加载消息
|
zh_Hans: 加载消息
|
||||||
|
zh_Hant: 載入訊息
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "AI正在思考中,请发送任意内容获取回复。"
|
default: "AI正在思考中,请发送任意内容获取回复。"
|
||||||
@@ -57,9 +82,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: API Base URL
|
en_US: API Base URL
|
||||||
zh_Hans: API 基础 URL
|
zh_Hans: API 基础 URL
|
||||||
|
zh_Hant: API 基礎 URL
|
||||||
description:
|
description:
|
||||||
en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.
|
en_US: API Base URL, used for accessing the Official Account API. If you are deploying in an internal network environment and accessing the Official Account API through a reverse proxy, please fill in this item according to the documentation.
|
||||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API,可根据文档修改此项
|
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API,可根据文档修改此项
|
||||||
|
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取微信公眾號 API,可根據文件修改此項
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: "https://api.weixin.qq.com"
|
default: "https://api.weixin.qq.com"
|
||||||
|
|||||||
577
src/langbot/pkg/platform/sources/openclaw_weixin.py
Normal file
577
src/langbot/pkg/platform/sources/openclaw_weixin.py
Normal file
@@ -0,0 +1,577 @@
|
|||||||
|
"""OpenClaw WeChat adapter for LangBot.
|
||||||
|
|
||||||
|
Uses the OpenClaw WeChat HTTP JSON API (long-poll getUpdates + sendMessage)
|
||||||
|
to integrate personal WeChat accounts with LangBot.
|
||||||
|
|
||||||
|
Reference: https://github.com/epiral/weixin-bot
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import traceback
|
||||||
|
import typing
|
||||||
|
|
||||||
|
import pydantic
|
||||||
|
import sqlalchemy
|
||||||
|
|
||||||
|
from langbot.libs.openclaw_weixin_api.client import (
|
||||||
|
DEFAULT_BASE_URL,
|
||||||
|
SESSION_EXPIRED_ERRCODE,
|
||||||
|
OpenClawWeixinClient,
|
||||||
|
)
|
||||||
|
from langbot.libs.openclaw_weixin_api.types import (
|
||||||
|
MessageItem,
|
||||||
|
WeixinMessage,
|
||||||
|
)
|
||||||
|
from langbot.pkg.entity.persistence import bot as persistence_bot
|
||||||
|
|
||||||
|
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||||
|
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||||
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawWeixinMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
|
"""Converts between LangBot MessageChain and OpenClaw WeChat message items."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def yiri2target(message_chain: platform_message.MessageChain) -> list[dict]:
|
||||||
|
"""Convert LangBot MessageChain to a list of OpenClaw message item dicts."""
|
||||||
|
items = []
|
||||||
|
for component in message_chain:
|
||||||
|
if isinstance(component, platform_message.Plain):
|
||||||
|
items.append({'type': MessageItem.TEXT, 'text_item': {'text': component.text}})
|
||||||
|
elif isinstance(component, platform_message.Image):
|
||||||
|
# OpenClaw WeChat only supports text messages without CDN upload.
|
||||||
|
# For images, we send a placeholder text with the URL if available.
|
||||||
|
if component.url:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
'type': MessageItem.TEXT,
|
||||||
|
'text_item': {'text': f'[Image: {component.url}]'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif component.base64:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
'type': MessageItem.TEXT,
|
||||||
|
'text_item': {'text': '[Image]'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif isinstance(component, platform_message.File):
|
||||||
|
if component.name:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
'type': MessageItem.TEXT,
|
||||||
|
'text_item': {'text': f'[File: {component.name}]'},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
elif isinstance(component, platform_message.Forward):
|
||||||
|
for node in component.node_list:
|
||||||
|
if node.message_chain:
|
||||||
|
items.extend(await OpenClawWeixinMessageConverter.yiri2target(node.message_chain))
|
||||||
|
return items
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def target2yiri(
|
||||||
|
msg: WeixinMessage,
|
||||||
|
) -> platform_message.MessageChain:
|
||||||
|
"""Convert an OpenClaw WeixinMessage to LangBot MessageChain."""
|
||||||
|
components: list[platform_message.MessageComponent] = []
|
||||||
|
|
||||||
|
if not msg.item_list:
|
||||||
|
return platform_message.MessageChain(components)
|
||||||
|
|
||||||
|
for item in msg.item_list:
|
||||||
|
if item.type == MessageItem.TEXT and item.text_item and item.text_item.text:
|
||||||
|
text = item.text_item.text
|
||||||
|
|
||||||
|
# Handle quoted messages
|
||||||
|
if item.ref_msg:
|
||||||
|
ref_parts = []
|
||||||
|
if item.ref_msg.title:
|
||||||
|
ref_parts.append(item.ref_msg.title)
|
||||||
|
if item.ref_msg.message_item:
|
||||||
|
ref_item = item.ref_msg.message_item
|
||||||
|
if ref_item.text_item and ref_item.text_item.text:
|
||||||
|
ref_parts.append(ref_item.text_item.text)
|
||||||
|
if ref_parts:
|
||||||
|
components.append(
|
||||||
|
platform_message.Quote(
|
||||||
|
sender_id='',
|
||||||
|
origin=platform_message.MessageChain(
|
||||||
|
[platform_message.Plain(text=' | '.join(ref_parts))]
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
components.append(platform_message.Plain(text=text))
|
||||||
|
|
||||||
|
elif item.type == MessageItem.IMAGE and item.image_item:
|
||||||
|
if hasattr(item.image_item, '_downloaded_bytes') and item.image_item._downloaded_bytes:
|
||||||
|
b64 = base64.b64encode(item.image_item._downloaded_bytes).decode('utf-8')
|
||||||
|
components.append(platform_message.Image(base64=f'data:image/jpeg;base64,{b64}'))
|
||||||
|
else:
|
||||||
|
components.append(platform_message.Unknown(text='[Image]'))
|
||||||
|
|
||||||
|
elif item.type == MessageItem.VOICE and item.voice_item:
|
||||||
|
# Voice with speech-to-text: use the transcribed text
|
||||||
|
if item.voice_item.text:
|
||||||
|
components.append(platform_message.Plain(text=item.voice_item.text))
|
||||||
|
else:
|
||||||
|
components.append(platform_message.Unknown(text='[Voice]'))
|
||||||
|
|
||||||
|
# TODO: enable after full testing
|
||||||
|
# elif item.type == MessageItem.VOICE and item.voice_item:
|
||||||
|
# if item.voice_item.text:
|
||||||
|
# components.append(platform_message.Plain(text=item.voice_item.text))
|
||||||
|
# elif hasattr(item.voice_item, '_downloaded_bytes') and item.voice_item._downloaded_bytes:
|
||||||
|
# b64 = base64.b64encode(item.voice_item._downloaded_bytes).decode('utf-8')
|
||||||
|
# components.append(
|
||||||
|
# platform_message.Voice(
|
||||||
|
# base64=b64,
|
||||||
|
# length=item.voice_item.playtime or 0,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
# else:
|
||||||
|
# components.append(
|
||||||
|
# platform_message.Voice(
|
||||||
|
# length=item.voice_item.playtime or 0,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
|
||||||
|
elif item.type == MessageItem.FILE and item.file_item:
|
||||||
|
components.append(platform_message.Unknown(text=f'[File: {item.file_item.file_name or ""}]'))
|
||||||
|
|
||||||
|
# TODO: enable after full testing
|
||||||
|
# elif item.type == MessageItem.FILE and item.file_item:
|
||||||
|
# file_name = item.file_item.file_name or ''
|
||||||
|
# file_size = int(item.file_item.len) if item.file_item.len else 0
|
||||||
|
# if hasattr(item.file_item, '_downloaded_bytes') and item.file_item._downloaded_bytes:
|
||||||
|
# b64 = base64.b64encode(item.file_item._downloaded_bytes).decode('utf-8')
|
||||||
|
# components.append(
|
||||||
|
# platform_message.File(
|
||||||
|
# name=file_name,
|
||||||
|
# size=file_size,
|
||||||
|
# base64=b64,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
# else:
|
||||||
|
# components.append(
|
||||||
|
# platform_message.File(
|
||||||
|
# name=file_name,
|
||||||
|
# size=file_size,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
|
||||||
|
elif item.type == MessageItem.VIDEO and item.video_item:
|
||||||
|
components.append(platform_message.Unknown(text='[Video]'))
|
||||||
|
|
||||||
|
# TODO: enable after full testing
|
||||||
|
# elif item.type == MessageItem.VIDEO and item.video_item:
|
||||||
|
# if hasattr(item.video_item, '_downloaded_bytes') and item.video_item._downloaded_bytes:
|
||||||
|
# b64 = base64.b64encode(item.video_item._downloaded_bytes).decode('utf-8')
|
||||||
|
# components.append(
|
||||||
|
# platform_message.File(
|
||||||
|
# name='video.mp4',
|
||||||
|
# size=item.video_item.video_size or 0,
|
||||||
|
# base64=b64,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
# else:
|
||||||
|
# components.append(
|
||||||
|
# platform_message.File(
|
||||||
|
# name='video.mp4',
|
||||||
|
# size=item.video_item.video_size or 0,
|
||||||
|
# )
|
||||||
|
# )
|
||||||
|
|
||||||
|
else:
|
||||||
|
components.append(platform_message.Unknown(text='[Unknown message type]'))
|
||||||
|
|
||||||
|
return platform_message.MessageChain(components)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawWeixinEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
|
"""Converts OpenClaw WeChat messages to LangBot events."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def yiri2target(event: platform_events.MessageEvent) -> dict:
|
||||||
|
return event.source_platform_object
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def target2yiri(msg: WeixinMessage) -> typing.Optional[platform_events.MessageEvent]:
|
||||||
|
"""Convert an inbound WeixinMessage to a LangBot event."""
|
||||||
|
if msg.message_type != WeixinMessage.TYPE_USER:
|
||||||
|
return None
|
||||||
|
|
||||||
|
from_user_id = msg.from_user_id or ''
|
||||||
|
if not from_user_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message_chain = await OpenClawWeixinMessageConverter.target2yiri(msg)
|
||||||
|
if not message_chain:
|
||||||
|
return None
|
||||||
|
|
||||||
|
timestamp = (msg.create_time_ms or 0) / 1000.0
|
||||||
|
|
||||||
|
return platform_events.FriendMessage(
|
||||||
|
sender=platform_entities.Friend(
|
||||||
|
id=from_user_id,
|
||||||
|
nickname=from_user_id,
|
||||||
|
remark='',
|
||||||
|
),
|
||||||
|
message_chain=message_chain,
|
||||||
|
time=timestamp,
|
||||||
|
source_platform_object=msg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenClawWeixinAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
|
"""LangBot adapter for OpenClaw WeChat (long-poll based)."""
|
||||||
|
|
||||||
|
name: str = 'openclaw-weixin'
|
||||||
|
|
||||||
|
client: OpenClawWeixinClient = pydantic.Field(exclude=True)
|
||||||
|
|
||||||
|
config: dict
|
||||||
|
|
||||||
|
message_converter: OpenClawWeixinMessageConverter = OpenClawWeixinMessageConverter()
|
||||||
|
event_converter: OpenClawWeixinEventConverter = OpenClawWeixinEventConverter()
|
||||||
|
|
||||||
|
# context_token cache: from_user_id -> context_token
|
||||||
|
_context_tokens: dict[str, str] = pydantic.PrivateAttr(default_factory=dict)
|
||||||
|
|
||||||
|
_polling: bool = pydantic.PrivateAttr(default=False)
|
||||||
|
_poll_task: typing.Optional[asyncio.Task] = pydantic.PrivateAttr(default=None)
|
||||||
|
_bot_uuid: typing.Optional[str] = pydantic.PrivateAttr(default=None)
|
||||||
|
|
||||||
|
listeners: typing.Dict[
|
||||||
|
typing.Type[platform_events.Event],
|
||||||
|
typing.Callable[[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None],
|
||||||
|
] = {}
|
||||||
|
|
||||||
|
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||||
|
client = OpenClawWeixinClient(
|
||||||
|
base_url=config.get('base_url', DEFAULT_BASE_URL),
|
||||||
|
token=config.get('token', ''),
|
||||||
|
)
|
||||||
|
super().__init__(
|
||||||
|
config=config,
|
||||||
|
logger=logger,
|
||||||
|
client=client,
|
||||||
|
bot_account_id='',
|
||||||
|
listeners={},
|
||||||
|
name='openclaw-weixin',
|
||||||
|
)
|
||||||
|
|
||||||
|
def set_bot_uuid(self, bot_uuid: str):
|
||||||
|
"""Called by BotManager to provide the bot's UUID for config persistence."""
|
||||||
|
self._bot_uuid = bot_uuid
|
||||||
|
|
||||||
|
async def _persist_config(self) -> None:
|
||||||
|
"""Persist current self.config to the database so token survives restart."""
|
||||||
|
if not self._bot_uuid:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
ap = self.logger.ap
|
||||||
|
await ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.update(persistence_bot.Bot)
|
||||||
|
.where(persistence_bot.Bot.uuid == self._bot_uuid)
|
||||||
|
.values(adapter_config=self.config)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.warning(f'Failed to persist adapter config: {e}')
|
||||||
|
|
||||||
|
async def _do_login(self) -> None:
|
||||||
|
"""Run the QR code login flow via client.login() and update config."""
|
||||||
|
adapter_logger = self.logger
|
||||||
|
|
||||||
|
async def _on_qrcode(qr_base64: str, _qr_url: str):
|
||||||
|
await adapter_logger.info(
|
||||||
|
f'Please scan the QR code to login WeChat: {_qr_url}',
|
||||||
|
images=[platform_message.Image(base64=qr_base64)],
|
||||||
|
)
|
||||||
|
|
||||||
|
login_result = await self.client.login(
|
||||||
|
on_qrcode=_on_qrcode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# client.login() already updates client.token and client.base_url
|
||||||
|
self.config['token'] = login_result.token
|
||||||
|
self.config['base_url'] = login_result.base_url
|
||||||
|
if login_result.account_id:
|
||||||
|
self.config['account_id'] = login_result.account_id
|
||||||
|
|
||||||
|
await self.logger.info(f'WeChat login successful! account_id={login_result.account_id}')
|
||||||
|
|
||||||
|
# Persist token to database so it survives restart
|
||||||
|
await self._persist_config()
|
||||||
|
|
||||||
|
async def send_message(
|
||||||
|
self,
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
):
|
||||||
|
"""Send a message to a user."""
|
||||||
|
context_token = self._context_tokens.get(target_id, '')
|
||||||
|
|
||||||
|
for component in message:
|
||||||
|
try:
|
||||||
|
if isinstance(component, platform_message.Plain):
|
||||||
|
if component.text:
|
||||||
|
await self.client.send_text(target_id, component.text, context_token)
|
||||||
|
|
||||||
|
elif isinstance(component, platform_message.Image):
|
||||||
|
img_bytes, _ = await component.get_bytes()
|
||||||
|
await self.client.send_image(target_id, img_bytes, context_token)
|
||||||
|
|
||||||
|
elif isinstance(component, platform_message.File):
|
||||||
|
file_bytes = await self._get_component_bytes(component)
|
||||||
|
if file_bytes:
|
||||||
|
await self.client.send_file(target_id, file_bytes, component.name or 'file', context_token)
|
||||||
|
|
||||||
|
elif isinstance(component, platform_message.Voice):
|
||||||
|
voice_bytes = await self._get_component_bytes(component)
|
||||||
|
if voice_bytes:
|
||||||
|
await self.client.send_voice(target_id, voice_bytes, component.length or 0, context_token)
|
||||||
|
|
||||||
|
elif isinstance(component, platform_message.Forward):
|
||||||
|
for node in component.node_list:
|
||||||
|
if node.message_chain:
|
||||||
|
await self.send_message(target_type, target_id, node.message_chain)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(
|
||||||
|
f'Failed to send component {type(component).__name__}: {traceback.format_exc()}'
|
||||||
|
)
|
||||||
|
|
||||||
|
async def reply_message(
|
||||||
|
self,
|
||||||
|
message_source: platform_events.MessageEvent,
|
||||||
|
message: platform_message.MessageChain,
|
||||||
|
quote_origin: bool = False,
|
||||||
|
):
|
||||||
|
"""Reply to a received message."""
|
||||||
|
source_msg = message_source.source_platform_object
|
||||||
|
if isinstance(source_msg, WeixinMessage):
|
||||||
|
target_id = source_msg.from_user_id or ''
|
||||||
|
if target_id:
|
||||||
|
await self.send_message('friend', target_id, message)
|
||||||
|
|
||||||
|
async def is_muted(self, group_id: int) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _get_component_bytes(component: platform_message.MessageComponent) -> typing.Optional[bytes]:
|
||||||
|
"""Extract raw bytes from a File or Voice component."""
|
||||||
|
b64_val = getattr(component, 'base64', None)
|
||||||
|
url_val = getattr(component, 'url', None)
|
||||||
|
path_val = getattr(component, 'path', None)
|
||||||
|
|
||||||
|
if b64_val:
|
||||||
|
return base64.b64decode(b64_val)
|
||||||
|
elif url_val and url_val.startswith(('http://', 'https://')):
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url_val) as resp:
|
||||||
|
if resp.status == 200:
|
||||||
|
return await resp.read()
|
||||||
|
elif path_val:
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
with open(path_val, 'rb') as f:
|
||||||
|
return await asyncio.to_thread(f.read)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def register_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
callback: typing.Callable[
|
||||||
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
|
||||||
|
None,
|
||||||
|
],
|
||||||
|
):
|
||||||
|
self.listeners[event_type] = callback
|
||||||
|
|
||||||
|
def unregister_listener(
|
||||||
|
self,
|
||||||
|
event_type: typing.Type[platform_events.Event],
|
||||||
|
callback: typing.Callable[
|
||||||
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter],
|
||||||
|
None,
|
||||||
|
],
|
||||||
|
):
|
||||||
|
self.listeners.pop(event_type, None)
|
||||||
|
|
||||||
|
async def run_async(self):
|
||||||
|
"""Start the adapter. If no token is configured, trigger QR code login first."""
|
||||||
|
base_url = self.config.get('base_url', DEFAULT_BASE_URL)
|
||||||
|
token = self.config.get('token', '')
|
||||||
|
|
||||||
|
await self.logger.info('OpenClaw WeChat adapter starting...')
|
||||||
|
|
||||||
|
# QR code login flow when no token is provided
|
||||||
|
if not token:
|
||||||
|
await self.logger.info('No token configured, starting QR code login...')
|
||||||
|
try:
|
||||||
|
await self._do_login()
|
||||||
|
except Exception as e:
|
||||||
|
await self.logger.error(f'QR code login failed: {e}')
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Rebuild client with the (possibly updated) config
|
||||||
|
self.client = OpenClawWeixinClient(
|
||||||
|
base_url=self.config.get('base_url', base_url),
|
||||||
|
token=self.config.get('token', token),
|
||||||
|
)
|
||||||
|
self.bot_account_id = self.config.get('account_id', 'openclaw-weixin')
|
||||||
|
self._polling = True
|
||||||
|
|
||||||
|
# Start the long-poll loop
|
||||||
|
self._poll_task = asyncio.create_task(self._poll_loop())
|
||||||
|
await self.logger.info('OpenClaw WeChat adapter running')
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._poll_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _poll_loop(self):
|
||||||
|
"""Long-poll loop: call getUpdates continuously.
|
||||||
|
|
||||||
|
Error handling follows the weixin-bot SDK pattern:
|
||||||
|
- Exponential backoff (1s -> 10s max) on failures
|
||||||
|
- Session expired (errcode -14) triggers automatic re-login
|
||||||
|
"""
|
||||||
|
get_updates_buf = ''
|
||||||
|
poll_timeout = float(self.config.get('poll_timeout', 35))
|
||||||
|
|
||||||
|
backoff_delay = 1.0
|
||||||
|
max_backoff = 10.0
|
||||||
|
|
||||||
|
while self._polling:
|
||||||
|
try:
|
||||||
|
resp = await self.client.get_updates(
|
||||||
|
get_updates_buf=get_updates_buf,
|
||||||
|
timeout=poll_timeout + 5,
|
||||||
|
)
|
||||||
|
|
||||||
|
if resp.longpolling_timeout_ms and resp.longpolling_timeout_ms > 0:
|
||||||
|
poll_timeout = resp.longpolling_timeout_ms / 1000.0
|
||||||
|
|
||||||
|
is_api_error = (resp.ret is not None and resp.ret != 0) or (
|
||||||
|
resp.errcode is not None and resp.errcode != 0
|
||||||
|
)
|
||||||
|
if is_api_error:
|
||||||
|
is_session_expired = resp.errcode == SESSION_EXPIRED_ERRCODE or resp.ret == SESSION_EXPIRED_ERRCODE
|
||||||
|
|
||||||
|
if is_session_expired:
|
||||||
|
await self.logger.error('OpenClaw WeChat session expired, attempting re-login...')
|
||||||
|
try:
|
||||||
|
await self._do_login()
|
||||||
|
# Rebuild client with new credentials
|
||||||
|
self.client = OpenClawWeixinClient(
|
||||||
|
base_url=self.config.get('base_url', DEFAULT_BASE_URL),
|
||||||
|
token=self.config.get('token', ''),
|
||||||
|
)
|
||||||
|
self._context_tokens.clear()
|
||||||
|
get_updates_buf = ''
|
||||||
|
backoff_delay = 1.0
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Re-login failed: {traceback.format_exc()}')
|
||||||
|
break
|
||||||
|
|
||||||
|
await self.logger.error(
|
||||||
|
f'OpenClaw getUpdates failed: ret={resp.ret} errcode={resp.errcode} errmsg={resp.errmsg}'
|
||||||
|
)
|
||||||
|
await asyncio.sleep(backoff_delay)
|
||||||
|
backoff_delay = min(backoff_delay * 2, max_backoff)
|
||||||
|
continue
|
||||||
|
|
||||||
|
backoff_delay = 1.0
|
||||||
|
|
||||||
|
if resp.get_updates_buf:
|
||||||
|
get_updates_buf = resp.get_updates_buf
|
||||||
|
|
||||||
|
for msg in resp.msgs:
|
||||||
|
try:
|
||||||
|
await self._handle_inbound_message(msg)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling message: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'OpenClaw poll error: {traceback.format_exc()}')
|
||||||
|
await asyncio.sleep(backoff_delay)
|
||||||
|
backoff_delay = min(backoff_delay * 2, max_backoff)
|
||||||
|
|
||||||
|
async def _handle_inbound_message(self, msg: WeixinMessage):
|
||||||
|
"""Process a single inbound message from getUpdates."""
|
||||||
|
if msg.context_token and msg.from_user_id:
|
||||||
|
self._context_tokens[msg.from_user_id] = msg.context_token
|
||||||
|
|
||||||
|
# Download CDN media (files, images) before converting to LangBot events
|
||||||
|
await self._download_media_items(msg)
|
||||||
|
|
||||||
|
event = await OpenClawWeixinEventConverter.target2yiri(msg)
|
||||||
|
if event is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if type(event) in self.listeners:
|
||||||
|
await self.listeners[type(event)](event, self)
|
||||||
|
|
||||||
|
async def _download_media_items(self, msg: WeixinMessage):
|
||||||
|
"""Download CDN media for image items in the message."""
|
||||||
|
if not msg.item_list:
|
||||||
|
return
|
||||||
|
|
||||||
|
for item in msg.item_list:
|
||||||
|
try:
|
||||||
|
if item.type == MessageItem.IMAGE and item.image_item:
|
||||||
|
if (
|
||||||
|
item.image_item.media
|
||||||
|
and item.image_item.media.encrypt_query_param
|
||||||
|
and item.image_item.media.aes_key
|
||||||
|
):
|
||||||
|
img_bytes = await self.client.download_media(item.image_item.media)
|
||||||
|
item.image_item._downloaded_bytes = img_bytes
|
||||||
|
|
||||||
|
# TODO: enable after full testing
|
||||||
|
# elif item.type == MessageItem.FILE and item.file_item and item.file_item.media:
|
||||||
|
# if item.file_item.media.encrypt_query_param and item.file_item.media.aes_key:
|
||||||
|
# file_bytes = await self.client.download_media(item.file_item.media)
|
||||||
|
# item.file_item._downloaded_bytes = file_bytes
|
||||||
|
#
|
||||||
|
# elif item.type == MessageItem.VOICE and item.voice_item and item.voice_item.media:
|
||||||
|
# if item.voice_item.media.encrypt_query_param and item.voice_item.media.aes_key:
|
||||||
|
# voice_bytes = await self.client.download_media(item.voice_item.media)
|
||||||
|
# item.voice_item._downloaded_bytes = voice_bytes
|
||||||
|
#
|
||||||
|
# elif item.type == MessageItem.VIDEO and item.video_item and item.video_item.media:
|
||||||
|
# if item.video_item.media.encrypt_query_param and item.video_item.media.aes_key:
|
||||||
|
# video_bytes = await self.client.download_media(item.video_item.media)
|
||||||
|
# item.video_item._downloaded_bytes = video_bytes
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
await self.logger.warning(f'Failed to download CDN media: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def kill(self) -> bool:
|
||||||
|
"""Stop the adapter."""
|
||||||
|
self._polling = False
|
||||||
|
if self._poll_task and not self._poll_task.done():
|
||||||
|
self._poll_task.cancel()
|
||||||
|
try:
|
||||||
|
await self._poll_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
await self.client.close()
|
||||||
|
await self.logger.info('OpenClaw WeChat adapter stopped')
|
||||||
|
return True
|
||||||
74
src/langbot/pkg/platform/sources/openclaw_weixin.yaml
Normal file
74
src/langbot/pkg/platform/sources/openclaw_weixin.yaml
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: MessagePlatformAdapter
|
||||||
|
metadata:
|
||||||
|
name: openclaw-weixin
|
||||||
|
label:
|
||||||
|
en_US: OpenClaw WeChat
|
||||||
|
zh_Hans: 个人微信机器人
|
||||||
|
zh_Hant: 個人微信機器人
|
||||||
|
description:
|
||||||
|
en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login
|
||||||
|
zh_Hans: 微信官方个人助手,扫码即可登录使用
|
||||||
|
zh_Hant: 微信官方個人助手,掃碼即可登入使用
|
||||||
|
icon: wechat.png
|
||||||
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/openclaw_weixin
|
||||||
|
en: https://link.langbot.app/en/platforms/openclaw_weixin
|
||||||
|
ja: https://link.langbot.app/ja/platforms/openclaw_weixin
|
||||||
|
config:
|
||||||
|
- name: base_url
|
||||||
|
label:
|
||||||
|
en_US: API Base URL
|
||||||
|
zh_Hans: API 基础地址
|
||||||
|
zh_Hant: API 基礎地址
|
||||||
|
description:
|
||||||
|
en_US: The base URL of the OpenClaw WeChat backend API
|
||||||
|
zh_Hans: OpenClaw 微信后端 API 的基础地址
|
||||||
|
zh_Hant: OpenClaw 微信後端 API 的基礎地址
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: "https://ilinkai.weixin.qq.com"
|
||||||
|
- name: token
|
||||||
|
label:
|
||||||
|
en_US: Token
|
||||||
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
|
description:
|
||||||
|
en_US: Bearer token obtained after QR code login authorization. Leave empty to trigger QR code login on startup.
|
||||||
|
zh_Hans: 扫码登录授权后获取的 Bearer 令牌。请留空并保存,将在启动时输出二维码到日志,扫码后即可自动登录。
|
||||||
|
zh_Hant: 掃碼登入授權後取得的 Bearer 令牌。請留空並儲存,將在啟動時輸出 QR Code 到日誌,掃碼後即可自動登入。
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
- name: account_id
|
||||||
|
label:
|
||||||
|
en_US: Account ID
|
||||||
|
zh_Hans: 账号标识
|
||||||
|
zh_Hant: 帳號標識
|
||||||
|
description:
|
||||||
|
en_US: A label for this WeChat account (used for display purposes)
|
||||||
|
zh_Hans: 此微信账号的标识(用于显示)
|
||||||
|
zh_Hant: 此微信帳號的標識(用於顯示)
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: "openclaw-weixin"
|
||||||
|
- name: poll_timeout
|
||||||
|
label:
|
||||||
|
en_US: Poll Timeout (seconds)
|
||||||
|
zh_Hans: 轮询超时(秒)
|
||||||
|
zh_Hant: 輪詢逾時(秒)
|
||||||
|
description:
|
||||||
|
en_US: Long-poll timeout for getUpdates, the server may hold the request up to this duration
|
||||||
|
zh_Hans: getUpdates 长轮询超时时间,服务端最多持有请求的时长
|
||||||
|
zh_Hant: getUpdates 長輪詢逾時時間,伺服端最多持有請求的時長
|
||||||
|
type: integer
|
||||||
|
required: false
|
||||||
|
default: 35
|
||||||
|
execution:
|
||||||
|
python:
|
||||||
|
path: ./openclaw_weixin.py
|
||||||
|
attr: OpenClawWeixinAdapter
|
||||||
@@ -5,16 +5,37 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: QQ Official API
|
en_US: QQ Official API
|
||||||
zh_Hans: QQ 官方 API
|
zh_Hans: QQ 官方 API
|
||||||
|
zh_Hant: QQ 官方 API
|
||||||
description:
|
description:
|
||||||
en_US: QQ Official API (Webhook)
|
en_US: QQ Official API (Webhook)
|
||||||
zh_Hans: QQ 官方 API (Webhook),请查看文档了解使用方式
|
zh_Hans: QQ 官方 API (Webhook),需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||||
|
zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||||
icon: qqofficial.svg
|
icon: qqofficial.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/qqofficial
|
||||||
|
en: https://link.langbot.app/en/platforms/qqofficial
|
||||||
|
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||||
config:
|
config:
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: appid
|
- name: appid
|
||||||
label:
|
label:
|
||||||
en_US: App ID
|
en_US: App ID
|
||||||
zh_Hans: 应用ID
|
zh_Hans: 应用ID
|
||||||
|
zh_Hant: 應用ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +43,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Secret
|
en_US: Secret
|
||||||
zh_Hans: 密钥
|
zh_Hans: 密钥
|
||||||
|
zh_Hant: 密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +51,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,36 +5,70 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Satori
|
en_US: Satori
|
||||||
zh_Hans: Satori
|
zh_Hans: Satori
|
||||||
|
zh_Hant: Satori
|
||||||
|
th_TH: Satori
|
||||||
|
vi_VN: Satori
|
||||||
|
es_ES: Satori
|
||||||
description:
|
description:
|
||||||
en_US: SatoriAdapter
|
en_US: SatoriAdapter
|
||||||
zh_Hans: 古明地觉协议适配器
|
zh_Hans: Satori 协议适配器,支持多种平台的接入,请查看文档了解使用方式
|
||||||
|
zh_Hant: Satori 協定適配器,支援多種平台的接入,請查看文件了解使用方式
|
||||||
|
th_TH: อะแดปเตอร์โปรโตคอล Satori รองรับการเชื่อมต่อหลายแพลตฟอร์ม โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
|
||||||
|
vi_VN: Bộ điều hợp giao thức Satori, hỗ trợ kết nối nhiều nền tảng, vui lòng xem tài liệu để biết cách sử dụng
|
||||||
|
es_ES: Adaptador del protocolo Satori, soporta acceso a múltiples plataformas, consulte la documentación para obtener instrucciones de uso
|
||||||
icon: satori.png
|
icon: satori.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- protocol
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/satori
|
||||||
|
en: https://link.langbot.app/en/platforms/satori
|
||||||
|
ja: https://link.langbot.app/ja/platforms/satori
|
||||||
config:
|
config:
|
||||||
- name: platform
|
- name: platform
|
||||||
label:
|
label:
|
||||||
en_US: Platform
|
en_US: Platform
|
||||||
zh_Hans: 平台名称
|
zh_Hans: 平台名称
|
||||||
|
zh_Hant: 平台名稱
|
||||||
|
th_TH: ชื่อแพลตฟอร์ม
|
||||||
|
vi_VN: Tên nền tảng
|
||||||
|
es_ES: Nombre de la plataforma
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "llonebot"
|
default: "llonebot"
|
||||||
description:
|
description:
|
||||||
en_US: The platform name (e.g., llonebot, discord, telegram)
|
en_US: The platform name (e.g., llonebot, discord, telegram)
|
||||||
zh_Hans: 平台名称(如 llonebot, discord, telegram)
|
zh_Hans: 平台名称(如 llonebot, discord, telegram)
|
||||||
|
zh_Hant: 平台名稱(如 llonebot、discord、telegram)
|
||||||
|
th_TH: ชื่อแพลตฟอร์ม (เช่น llonebot, discord, telegram)
|
||||||
|
vi_VN: "Tên nền tảng (ví dụ: llonebot, discord, telegram)"
|
||||||
|
es_ES: El nombre de la plataforma (p. ej., llonebot, discord, telegram)
|
||||||
- name: host
|
- name: host
|
||||||
label:
|
label:
|
||||||
en_US: Host
|
en_US: Host
|
||||||
zh_Hans: 主机地址
|
zh_Hans: 主机地址
|
||||||
|
zh_Hant: 主機地址
|
||||||
|
th_TH: ที่อยู่โฮสต์
|
||||||
|
vi_VN: Địa chỉ máy chủ
|
||||||
|
es_ES: Dirección del host
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "127.0.0.1"
|
default: "127.0.0.1"
|
||||||
description:
|
description:
|
||||||
en_US: The host address of LLOneBot Satori server (e.g., 127.0.0.1, localhost, 192.168.1.100)
|
en_US: The host address of LLOneBot Satori server (e.g., 127.0.0.1, localhost, 192.168.1.100)
|
||||||
zh_Hans: LLOneBot Satori服务器的主机地址(如 127.0.0.1, localhost, 192.168.1.100)
|
zh_Hans: LLOneBot Satori服务器的主机地址(如 127.0.0.1, localhost, 192.168.1.100)
|
||||||
|
zh_Hant: LLOneBot Satori 伺服器的主機地址(如 127.0.0.1、localhost、192.168.1.100)
|
||||||
|
th_TH: ที่อยู่โฮสต์ของเซิร์ฟเวอร์ LLOneBot Satori (เช่น 127.0.0.1, localhost, 192.168.1.100)
|
||||||
|
vi_VN: "Địa chỉ máy chủ LLOneBot Satori (ví dụ: 127.0.0.1, localhost, 192.168.1.100)"
|
||||||
|
es_ES: La dirección del host del servidor LLOneBot Satori (p. ej., 127.0.0.1, localhost, 192.168.1.100)
|
||||||
- name: port
|
- name: port
|
||||||
label:
|
label:
|
||||||
en_US: Port
|
en_US: Port
|
||||||
zh_Hans: 监听端口
|
zh_Hans: 监听端口
|
||||||
|
zh_Hant: 監聽連接埠
|
||||||
|
th_TH: พอร์ต
|
||||||
|
vi_VN: Cổng
|
||||||
|
es_ES: Puerto
|
||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 5600
|
default: 5600
|
||||||
@@ -42,6 +76,10 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Satori API Endpoint
|
en_US: Satori API Endpoint
|
||||||
zh_Hans: Satori API 终结点
|
zh_Hans: Satori API 终结点
|
||||||
|
zh_Hant: Satori API 端點
|
||||||
|
th_TH: จุดปลาย Satori API
|
||||||
|
vi_VN: Điểm cuối Satori API
|
||||||
|
es_ES: Punto de acceso de la API Satori
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "http://localhost:5600/v1"
|
default: "http://localhost:5600/v1"
|
||||||
@@ -49,6 +87,10 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Satori WebSocket Endpoint
|
en_US: Satori WebSocket Endpoint
|
||||||
zh_Hans: Satori WebSocket 终结点
|
zh_Hans: Satori WebSocket 终结点
|
||||||
|
zh_Hant: Satori WebSocket 端點
|
||||||
|
th_TH: จุดปลาย Satori WebSocket
|
||||||
|
vi_VN: Điểm cuối Satori WebSocket
|
||||||
|
es_ES: Punto de acceso WebSocket de Satori
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: "ws://localhost:5600/v1/events"
|
default: "ws://localhost:5600/v1/events"
|
||||||
@@ -56,6 +98,10 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
|
th_TH: โทเค็น
|
||||||
|
vi_VN: Mã thông báo
|
||||||
|
es_ES: Token
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -5,16 +5,58 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Slack
|
en_US: Slack
|
||||||
zh_Hans: Slack
|
zh_Hans: Slack
|
||||||
|
zh_Hant: Slack
|
||||||
|
ja_JP: Slack
|
||||||
|
th_TH: Slack
|
||||||
|
vi_VN: Slack
|
||||||
|
es_ES: Slack
|
||||||
description:
|
description:
|
||||||
en_US: Slack Adapter
|
en_US: Slack Adapter
|
||||||
zh_Hans: Slack 适配器,请查看文档了解使用方式
|
zh_Hans: Slack 适配器,需要公网地址以接收 Slack 消息推送,请查看文档了解使用方式
|
||||||
|
zh_Hant: Slack 適配器,需要公網地址以接收 Slack 訊息推送,請查看文件了解使用方式
|
||||||
|
ja_JP: Slack アダプター、Slackのメッセージプッシュを受信するためにパブリックURLが必要です。使用方法の詳細については、ドキュメントを参照してください。
|
||||||
|
th_TH: อะแดปเตอร์ Slack ต้องการที่อยู่สาธารณะเพื่อรับการแจ้งเตือนข้อความจาก Slack โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
|
||||||
|
vi_VN: Bộ điều hợp Slack, cần địa chỉ công cộng để nhận thông báo tin nhắn từ Slack, vui lòng xem tài liệu để biết cách sử dụng
|
||||||
|
es_ES: Adaptador de Slack, requiere una dirección pública para recibir notificaciones de mensajes de Slack, consulte la documentación para obtener instrucciones de uso
|
||||||
icon: slack.png
|
icon: slack.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/slack
|
||||||
|
en: https://link.langbot.app/en/platforms/slack
|
||||||
|
ja: https://link.langbot.app/ja/platforms/slack
|
||||||
config:
|
config:
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
ja_JP: Webhook コールバック URL
|
||||||
|
th_TH: URL การเรียกกลับ Webhook
|
||||||
|
vi_VN: URL gọi lại Webhook
|
||||||
|
es_ES: URL de devolución de llamada Webhook
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your Slack app's event subscription configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到 Slack 应用的事件订阅配置中
|
||||||
|
zh_Hant: 複製此地址並貼到 Slack 應用的事件訂閱設定中
|
||||||
|
ja_JP: この URL をコピーして Slack アプリのイベントサブスクリプション設定に貼り付けてください
|
||||||
|
th_TH: คัดลอก URL นี้แล้ววางในการตั้งค่าการสมัครรับเหตุการณ์ของแอป Slack ของคุณ
|
||||||
|
vi_VN: Sao chép URL này và dán vào cấu hình đăng ký sự kiện của ứng dụng Slack của bạn
|
||||||
|
es_ES: Copie esta URL y péguela en la configuración de suscripción de eventos de su aplicación Slack
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: bot_token
|
- name: bot_token
|
||||||
label:
|
label:
|
||||||
en_US: Bot Token
|
en_US: Bot Token
|
||||||
zh_Hans: 机器人令牌
|
zh_Hans: 机器人令牌
|
||||||
|
zh_Hant: 機器人令牌
|
||||||
|
ja_JP: ボットトークン
|
||||||
|
th_TH: โทเค็นบอท
|
||||||
|
vi_VN: Mã thông báo Bot
|
||||||
|
es_ES: Token del bot
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +64,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: signing_secret
|
en_US: signing_secret
|
||||||
zh_Hans: 密钥
|
zh_Hans: 密钥
|
||||||
|
zh_Hant: 密鑰
|
||||||
|
ja_JP: 署名シークレット
|
||||||
|
th_TH: คีย์ลายเซ็น
|
||||||
|
vi_VN: Khóa ký
|
||||||
|
es_ES: Secreto de firma
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -42,6 +42,25 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
photo_bytes = f.read()
|
photo_bytes = f.read()
|
||||||
|
|
||||||
components.append({'type': 'photo', 'photo': photo_bytes})
|
components.append({'type': 'photo', 'photo': photo_bytes})
|
||||||
|
elif isinstance(component, platform_message.File):
|
||||||
|
file_bytes = None
|
||||||
|
|
||||||
|
if component.base64:
|
||||||
|
# Strip data URI prefix if present (e.g. "data:application/pdf;base64,...")
|
||||||
|
b64_data = component.base64
|
||||||
|
if ';base64,' in b64_data:
|
||||||
|
b64_data = b64_data.split(';base64,', 1)[1]
|
||||||
|
file_bytes = base64.b64decode(b64_data)
|
||||||
|
elif component.url:
|
||||||
|
session = httpclient.get_session()
|
||||||
|
async with session.get(component.url) as response:
|
||||||
|
file_bytes = await response.read()
|
||||||
|
elif component.path:
|
||||||
|
with open(component.path, 'rb') as f:
|
||||||
|
file_bytes = f.read()
|
||||||
|
|
||||||
|
file_name = getattr(component, 'name', None) or 'file'
|
||||||
|
components.append({'type': 'document', 'document': file_bytes, 'filename': file_name})
|
||||||
elif isinstance(component, platform_message.Forward):
|
elif isinstance(component, platform_message.Forward):
|
||||||
for node in component.node_list:
|
for node in component.node_list:
|
||||||
components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot))
|
components.extend(await TelegramMessageConverter.yiri2target(node.message_chain, bot))
|
||||||
@@ -104,6 +123,27 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if message.document:
|
||||||
|
if message.caption:
|
||||||
|
message_components.extend(parse_message_text(message.caption))
|
||||||
|
|
||||||
|
file = await message.document.get_file()
|
||||||
|
file_name = message.document.file_name or 'document'
|
||||||
|
file_size = message.document.file_size or 0
|
||||||
|
file_format = message.document.mime_type or 'application/octet-stream'
|
||||||
|
|
||||||
|
file_bytes = None
|
||||||
|
async with httpclient.get_session(trust_env=True).get(file.file_path) as response:
|
||||||
|
file_bytes = await response.read()
|
||||||
|
|
||||||
|
message_components.append(
|
||||||
|
platform_message.File(
|
||||||
|
name=file_name,
|
||||||
|
size=file_size,
|
||||||
|
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return platform_message.MessageChain(message_components)
|
return platform_message.MessageChain(message_components)
|
||||||
|
|
||||||
|
|
||||||
@@ -179,7 +219,10 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
application = ApplicationBuilder().token(config['token']).build()
|
application = ApplicationBuilder().token(config['token']).build()
|
||||||
bot = application.bot
|
bot = application.bot
|
||||||
application.add_handler(
|
application.add_handler(
|
||||||
MessageHandler(filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE, telegram_callback)
|
MessageHandler(
|
||||||
|
filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE | filters.Document.ALL,
|
||||||
|
telegram_callback,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
super().__init__(
|
super().__init__(
|
||||||
config=config,
|
config=config,
|
||||||
@@ -218,6 +261,13 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
continue
|
continue
|
||||||
args['photo'] = telegram.InputFile(photo)
|
args['photo'] = telegram.InputFile(photo)
|
||||||
await self.bot.send_photo(**args)
|
await self.bot.send_photo(**args)
|
||||||
|
elif component_type == 'document':
|
||||||
|
doc = component.get('document')
|
||||||
|
if doc is None:
|
||||||
|
continue
|
||||||
|
filename = component.get('filename', 'file')
|
||||||
|
args['document'] = telegram.InputFile(doc, filename=filename)
|
||||||
|
await self.bot.send_document(**args)
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -5,23 +5,50 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: Telegram
|
en_US: Telegram
|
||||||
zh_Hans: 电报
|
zh_Hans: 电报
|
||||||
|
zh_Hant: Telegram
|
||||||
|
ja_JP: Telegram
|
||||||
|
th_TH: Telegram
|
||||||
|
vi_VN: Telegram
|
||||||
|
es_ES: Telegram
|
||||||
description:
|
description:
|
||||||
en_US: Telegram Adapter
|
en_US: Telegram Adapter
|
||||||
zh_Hans: 电报适配器,请查看文档了解使用方式
|
zh_Hans: Telegram 适配器,请查看文档了解使用方式
|
||||||
|
zh_Hant: Telegram 適配器,請查看文件了解使用方式
|
||||||
|
ja_JP: Telegram アダプター。使用方法の詳細については、ドキュメントを参照してください。
|
||||||
|
th_TH: อะแดปเตอร์ Telegram โปรดดูเอกสารประกอบสำหรับวิธีการใช้งาน
|
||||||
|
vi_VN: Bộ điều hợp Telegram, vui lòng xem tài liệu để biết cách sử dụng
|
||||||
|
es_ES: Adaptador de Telegram, consulte la documentación para obtener instrucciones de uso
|
||||||
icon: telegram.svg
|
icon: telegram.svg
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- global
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/telegram
|
||||||
|
en: https://link.langbot.app/en/platforms/telegram
|
||||||
|
ja: https://link.langbot.app/ja/platforms/telegram
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
|
ja_JP: トークン
|
||||||
|
th_TH: โทเค็น
|
||||||
|
vi_VN: Mã thông báo
|
||||||
|
es_ES: Token
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: "token_from_botfather"
|
||||||
- name: markdown_card
|
- name: markdown_card
|
||||||
label:
|
label:
|
||||||
en_US: Markdown Card
|
en_US: Markdown Card
|
||||||
zh_Hans: 是否使用 Markdown 卡片
|
zh_Hans: 是否使用 Markdown 卡片
|
||||||
|
zh_Hant: 是否使用 Markdown 卡片
|
||||||
|
ja_JP: Markdown カードを使用
|
||||||
|
th_TH: การ์ด Markdown
|
||||||
|
vi_VN: Thẻ Markdown
|
||||||
|
es_ES: Tarjeta Markdown
|
||||||
type: boolean
|
type: boolean
|
||||||
required: false
|
required: false
|
||||||
default: true
|
default: true
|
||||||
@@ -29,9 +56,19 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Enable Stream Reply Mode
|
en_US: Enable Stream Reply Mode
|
||||||
zh_Hans: 启用电报流式回复模式
|
zh_Hans: 启用电报流式回复模式
|
||||||
|
zh_Hant: 啟用 Telegram 串流回覆模式
|
||||||
|
ja_JP: ストリーミング返信モードを有効化
|
||||||
|
th_TH: เปิดใช้งานโหมดตอบกลับแบบสตรีม
|
||||||
|
vi_VN: Bật chế độ trả lời trực tuyến
|
||||||
|
es_ES: Habilitar modo de respuesta en streaming
|
||||||
description:
|
description:
|
||||||
en_US: If enabled, the bot will use the stream of telegram reply mode
|
en_US: If enabled, the bot will use the stream of telegram reply mode
|
||||||
zh_Hans: 如果启用,将使用电报流式方式来回复内容
|
zh_Hans: 如果启用,将使用电报流式方式来回复内容
|
||||||
|
zh_Hant: 如果啟用,將使用 Telegram 串流方式來回覆內容
|
||||||
|
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||||
|
th_TH: หากเปิดใช้งาน บอทจะใช้โหมดสตรีมของ Telegram ในการตอบกลับ
|
||||||
|
vi_VN: Nếu bật, bot sẽ sử dụng chế độ trả lời trực tuyến của Telegram
|
||||||
|
es_ES: Si está habilitado, el bot usará el modo de respuesta en streaming de Telegram
|
||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
|||||||
@@ -5,11 +5,21 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: "WebSocket Chat"
|
en_US: "WebSocket Chat"
|
||||||
zh_Hans: "WebSocket 聊天"
|
zh_Hans: "WebSocket 聊天"
|
||||||
|
zh_Hant: "WebSocket 聊天"
|
||||||
|
th_TH: "แชท WebSocket"
|
||||||
|
vi_VN: "Trò chuyện WebSocket"
|
||||||
|
es_ES: "Chat WebSocket"
|
||||||
description:
|
description:
|
||||||
en_US: "WebSocket adapter for bidirectional real-time communication"
|
en_US: "WebSocket adapter for bidirectional real-time communication"
|
||||||
zh_Hans: "用于双向实时通信的 WebSocket 适配器"
|
zh_Hans: "用于双向实时通信的 WebSocket 适配器"
|
||||||
|
zh_Hant: "用於雙向即時通訊的 WebSocket 適配器"
|
||||||
|
th_TH: "อะแดปเตอร์ WebSocket สำหรับการสื่อสารแบบเรียลไทม์สองทิศทาง"
|
||||||
|
vi_VN: "Bộ điều hợp WebSocket cho giao tiếp thời gian thực hai chiều"
|
||||||
|
es_ES: "Adaptador WebSocket para comunicación bidireccional en tiempo real"
|
||||||
icon: ""
|
icon: ""
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- protocol
|
||||||
config: []
|
config: []
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
|
|||||||
@@ -37,16 +37,24 @@ class WebSocketSession:
|
|||||||
id: str
|
id: str
|
||||||
message_lists: dict[str, list[WebSocketMessage]] = {}
|
message_lists: dict[str, list[WebSocketMessage]] = {}
|
||||||
"""消息列表 {pipeline_uuid: [messages]}"""
|
"""消息列表 {pipeline_uuid: [messages]}"""
|
||||||
|
stream_message_indexes: dict[str, dict[str, int]] = {}
|
||||||
|
"""流式消息索引 {pipeline_uuid: {resp_message_id: message_index}}"""
|
||||||
|
|
||||||
def __init__(self, id: str):
|
def __init__(self, id: str):
|
||||||
self.id = id
|
self.id = id
|
||||||
self.message_lists = {}
|
self.message_lists = {}
|
||||||
|
self.stream_message_indexes = {}
|
||||||
|
|
||||||
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
|
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
|
||||||
if pipeline_uuid not in self.message_lists:
|
if pipeline_uuid not in self.message_lists:
|
||||||
self.message_lists[pipeline_uuid] = []
|
self.message_lists[pipeline_uuid] = []
|
||||||
return self.message_lists[pipeline_uuid]
|
return self.message_lists[pipeline_uuid]
|
||||||
|
|
||||||
|
def get_stream_message_indexes(self, pipeline_uuid: str) -> dict[str, int]:
|
||||||
|
if pipeline_uuid not in self.stream_message_indexes:
|
||||||
|
self.stream_message_indexes[pipeline_uuid] = {}
|
||||||
|
return self.stream_message_indexes[pipeline_uuid]
|
||||||
|
|
||||||
|
|
||||||
class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
"""WebSocket适配器 - 支持双向实时通信"""
|
"""WebSocket适配器 - 支持双向实时通信"""
|
||||||
@@ -89,20 +97,46 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
target_id: str,
|
target_id: str,
|
||||||
message: platform_message.MessageChain,
|
message: platform_message.MessageChain,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""发送消息 - 这里用于主动推送消息到前端"""
|
"""发送消息 - 这里用于主动推送消息到前端
|
||||||
message_data = {
|
|
||||||
'type': 'bot_message',
|
|
||||||
'target_type': target_type,
|
|
||||||
'target_id': target_id,
|
|
||||||
'content': str(message),
|
|
||||||
'message_chain': [component.__dict__ for component in message],
|
|
||||||
'timestamp': datetime.now().isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
# 推送到所有相关连接
|
对于 WebSocket 适配器,我们需要将消息广播到正确的 pipeline 连接。
|
||||||
await self.outbound_message_queue.put(message_data)
|
target_id 可能是 launcher_id(如 websocket_xxx)或 pipeline_uuid。
|
||||||
|
我们需要尝试两种方式来确保消息能够送达。
|
||||||
|
"""
|
||||||
|
# 获取当前的 pipeline_uuid
|
||||||
|
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||||
|
session_type = 'group' if target_type == 'group' else 'person'
|
||||||
|
|
||||||
return message_data
|
# 选择会话
|
||||||
|
session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
||||||
|
|
||||||
|
# 生成唯一消息ID
|
||||||
|
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
|
||||||
|
|
||||||
|
message_data = WebSocketMessage(
|
||||||
|
id=msg_id,
|
||||||
|
role='assistant',
|
||||||
|
content=str(message),
|
||||||
|
message_chain=[component.__dict__ for component in message],
|
||||||
|
timestamp=datetime.now().isoformat(),
|
||||||
|
is_final=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 保存到历史记录
|
||||||
|
session.get_message_list(pipeline_uuid).append(message_data)
|
||||||
|
|
||||||
|
# 直接广播到当前pipeline的连接
|
||||||
|
await ws_connection_manager.broadcast_to_pipeline(
|
||||||
|
pipeline_uuid,
|
||||||
|
{
|
||||||
|
'type': 'response',
|
||||||
|
'session_type': session_type,
|
||||||
|
'data': message_data.model_dump(),
|
||||||
|
},
|
||||||
|
session_type=session_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
return message_data.model_dump()
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
@@ -169,10 +203,16 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||||
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||||
message_list = session.get_message_list(pipeline_uuid)
|
message_list = session.get_message_list(pipeline_uuid)
|
||||||
|
stream_message_indexes = session.get_stream_message_indexes(pipeline_uuid)
|
||||||
|
|
||||||
# 检查是否是新的流式消息(通过bot_message对象判断)
|
# Streaming messages in LangBot have a stable resp_message_id during the same assistant reply.
|
||||||
# 如果列表为空,或者最后一条消息已经is_final=True,则创建新消息
|
# Use it as the primary key to avoid overwriting an old card from a previous reply.
|
||||||
if not message_list or message_list[-1].is_final:
|
resp_message_id = str(getattr(bot_message, 'resp_message_id', '') or '')
|
||||||
|
existing_index = stream_message_indexes.get(resp_message_id) if resp_message_id else None
|
||||||
|
|
||||||
|
message_is_final = is_final and bot_message.tool_calls is None
|
||||||
|
|
||||||
|
if existing_index is None or existing_index >= len(message_list):
|
||||||
# 创建新消息
|
# 创建新消息
|
||||||
msg_id = len(message_list) + 1
|
msg_id = len(message_list) + 1
|
||||||
message_data = WebSocketMessage(
|
message_data = WebSocketMessage(
|
||||||
@@ -181,27 +221,31 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
content=str(message),
|
content=str(message),
|
||||||
message_chain=[component.__dict__ for component in message],
|
message_chain=[component.__dict__ for component in message],
|
||||||
timestamp=datetime.now().isoformat(),
|
timestamp=datetime.now().isoformat(),
|
||||||
is_final=is_final and bot_message.tool_calls is None,
|
is_final=message_is_final,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 只有在is_final时才保存到历史记录
|
# 立即添加到历史记录(即使is_final=False),以便后续块可以更新它
|
||||||
if is_final and bot_message.tool_calls is None:
|
message_list.append(message_data)
|
||||||
message_list.append(message_data)
|
if resp_message_id:
|
||||||
|
stream_message_indexes[resp_message_id] = len(message_list) - 1
|
||||||
else:
|
else:
|
||||||
# 更新最后一条消息
|
# 更新同一条流式消息
|
||||||
msg_id = message_list[-1].id
|
old_message = message_list[existing_index]
|
||||||
|
msg_id = old_message.id
|
||||||
message_data = WebSocketMessage(
|
message_data = WebSocketMessage(
|
||||||
id=msg_id,
|
id=msg_id,
|
||||||
role='assistant',
|
role='assistant',
|
||||||
content=str(message),
|
content=str(message),
|
||||||
message_chain=[component.__dict__ for component in message],
|
message_chain=[component.__dict__ for component in message],
|
||||||
timestamp=message_list[-1].timestamp, # 保持原始时间戳
|
timestamp=old_message.timestamp, # 保持原始时间戳
|
||||||
is_final=is_final and bot_message.tool_calls is None,
|
is_final=message_is_final,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 如果是final,更新历史记录中的最后一条
|
# 更新历史记录中的对应消息
|
||||||
if is_final and bot_message.tool_calls is None:
|
message_list[existing_index] = message_data
|
||||||
message_list[-1] = message_data
|
|
||||||
|
if message_is_final and resp_message_id:
|
||||||
|
stream_message_indexes.pop(resp_message_id, None)
|
||||||
|
|
||||||
# 直接广播到所有该pipeline的连接,包含session_type信息
|
# 直接广播到所有该pipeline的连接,包含session_type信息
|
||||||
await ws_connection_manager.broadcast_to_pipeline(
|
await ws_connection_manager.broadcast_to_pipeline(
|
||||||
@@ -410,6 +454,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
if session_type == 'person':
|
if session_type == 'person':
|
||||||
if pipeline_uuid in self.websocket_person_session.message_lists:
|
if pipeline_uuid in self.websocket_person_session.message_lists:
|
||||||
self.websocket_person_session.message_lists[pipeline_uuid] = []
|
self.websocket_person_session.message_lists[pipeline_uuid] = []
|
||||||
|
if pipeline_uuid in self.websocket_person_session.stream_message_indexes:
|
||||||
|
self.websocket_person_session.stream_message_indexes[pipeline_uuid] = {}
|
||||||
else:
|
else:
|
||||||
if pipeline_uuid in self.websocket_group_session.message_lists:
|
if pipeline_uuid in self.websocket_group_session.message_lists:
|
||||||
self.websocket_group_session.message_lists[pipeline_uuid] = []
|
self.websocket_group_session.message_lists[pipeline_uuid] = []
|
||||||
|
if pipeline_uuid in self.websocket_group_session.stream_message_indexes:
|
||||||
|
self.websocket_group_session.stream_message_indexes[pipeline_uuid] = {}
|
||||||
|
|||||||
BIN
src/langbot/pkg/platform/sources/wechat.png
Normal file
BIN
src/langbot/pkg/platform/sources/wechat.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 466 KiB |
@@ -4,17 +4,26 @@ metadata:
|
|||||||
name: wechatpad
|
name: wechatpad
|
||||||
label:
|
label:
|
||||||
en_US: WeChatPad
|
en_US: WeChatPad
|
||||||
zh_CN: WeChatPad(个人微信ipad)
|
zh_Hans: WeChatPad(个人微信ipad)
|
||||||
|
zh_Hant: WeChatPad(個人微信iPad)
|
||||||
description:
|
description:
|
||||||
en_US: WeChatPad Adapter
|
en_US: WeChatPad Adapter
|
||||||
zh_CN: WeChatPad 适配器
|
zh_Hans: WeChatPad 适配器,基于WeChatPad的个人微信解决方案,请查看文档了解使用方式
|
||||||
|
zh_Hant: WeChatPad 適配器,基於 WeChatPad 的個人微信解決方案,請查看文件了解使用方式
|
||||||
icon: wechatpad.png
|
icon: wechatpad.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/wechatpad
|
||||||
|
en: https://link.langbot.app/en/platforms/wechatpad
|
||||||
|
ja: https://link.langbot.app/ja/platforms/wechatpad
|
||||||
config:
|
config:
|
||||||
- name: wechatpad_url
|
- name: wechatpad_url
|
||||||
label:
|
label:
|
||||||
en_US: WeChatPad ERL
|
en_US: WeChatPad ERL
|
||||||
zh_CN: WeChatPad URL
|
zh_CN: WeChatPad URL
|
||||||
|
zh_Hant: WeChatPad URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +31,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: WeChatPad_Ws
|
en_US: WeChatPad_Ws
|
||||||
zh_CN: WeChatPad_Ws
|
zh_CN: WeChatPad_Ws
|
||||||
|
zh_Hant: WeChatPad_Ws
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +39,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Admin_Key
|
en_US: Admin_Key
|
||||||
zh_CN: 管理员密匙
|
zh_CN: 管理员密匙
|
||||||
|
zh_Hant: 管理員密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -36,6 +47,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_CN: 令牌
|
zh_CN: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -43,6 +55,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: wxid
|
en_US: wxid
|
||||||
zh_CN: wxid
|
zh_CN: wxid
|
||||||
|
zh_Hant: wxid
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
|
|||||||
@@ -148,51 +148,54 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
if type(event) is platform_events.FriendMessage:
|
if type(event) is platform_events.FriendMessage:
|
||||||
payload = {
|
return event.source_platform_object
|
||||||
'MsgType': 'text',
|
|
||||||
'Content': '',
|
|
||||||
'FromUserName': event.sender.id,
|
|
||||||
'ToUserName': bot_account_id,
|
|
||||||
'CreateTime': int(datetime.datetime.now().timestamp()),
|
|
||||||
'AgentID': event.sender.nickname,
|
|
||||||
}
|
|
||||||
wecom_event = WecomEvent.from_payload(payload=payload)
|
|
||||||
if not wecom_event:
|
|
||||||
raise ValueError('无法从 message_data 构造 WecomEvent 对象')
|
|
||||||
|
|
||||||
return wecom_event
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomEvent):
|
async def target2yiri(event: WecomEvent, bot: WecomClient = None):
|
||||||
"""
|
"""
|
||||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event (WecomEvent): 企业微信事件。
|
event (WecomEvent): 企业微信事件。
|
||||||
|
bot (WecomClient): 企业微信客户端,用于获取用户信息。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
||||||
"""
|
"""
|
||||||
|
# Try to get the user's real name from the WeCom API
|
||||||
|
nickname = str(event.user_id)
|
||||||
|
if bot and event.user_id:
|
||||||
|
try:
|
||||||
|
user_info = await bot.get_user_info(event.user_id)
|
||||||
|
if user_info and user_info.get('name'):
|
||||||
|
nickname = user_info.get('name')
|
||||||
|
except Exception:
|
||||||
|
pass # Fall back to user_id as nickname
|
||||||
|
|
||||||
# 转换消息链
|
# 转换消息链
|
||||||
if event.type == 'text':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=str(event.agent_id),
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
return platform_events.FriendMessage(
|
||||||
|
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
||||||
|
)
|
||||||
elif event.type == 'image':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=str(event.agent_id),
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri_image(picurl=event.picurl, message_id=event.message_id)
|
||||||
|
|
||||||
return platform_events.FriendMessage(sender=friend, message_chain=yiri_chain, time=event.timestamp)
|
return platform_events.FriendMessage(
|
||||||
|
sender=friend, message_chain=yiri_chain, time=event.timestamp, source_platform_object=event
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
@@ -210,7 +213,6 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
'secret',
|
'secret',
|
||||||
'token',
|
'token',
|
||||||
'EncodingAESKey',
|
'EncodingAESKey',
|
||||||
'contacts_secret',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
missing_keys = [key for key in required_keys if key not in config]
|
missing_keys = [key for key in required_keys if key not in config]
|
||||||
@@ -223,7 +225,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
secret=config['secret'],
|
secret=config['secret'],
|
||||||
token=config['token'],
|
token=config['token'],
|
||||||
EncodingAESKey=config['EncodingAESKey'],
|
EncodingAESKey=config['EncodingAESKey'],
|
||||||
contacts_secret=config['contacts_secret'],
|
contacts_secret=config.get('contacts_secret', ''), # Optional, kept for backward compatibility
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=True,
|
unified_mode=True,
|
||||||
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
|
api_base_url=config.get('api_base_url', 'https://qyapi.weixin.qq.com/cgi-bin'),
|
||||||
@@ -248,18 +250,17 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
):
|
):
|
||||||
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
|
Wecom_event = await WecomEventConverter.yiri2target(message_source, self.bot_account_id, self.bot)
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
fixed_user_id = Wecom_event.user_id
|
# user_id is the original FromUserName from WecomEvent
|
||||||
# 删掉开头的u
|
user_id = Wecom_event.user_id
|
||||||
fixed_user_id = fixed_user_id[1:]
|
|
||||||
for content in content_list:
|
for content in content_list:
|
||||||
if content['type'] == 'text':
|
if content['type'] == 'text':
|
||||||
await self.bot.send_private_msg(fixed_user_id, Wecom_event.agent_id, content['content'])
|
await self.bot.send_private_msg(user_id, Wecom_event.agent_id, content['content'])
|
||||||
elif content['type'] == 'image':
|
elif content['type'] == 'image':
|
||||||
await self.bot.send_image(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
await self.bot.send_image(user_id, Wecom_event.agent_id, content['media_id'])
|
||||||
elif content['type'] == 'voice':
|
elif content['type'] == 'voice':
|
||||||
await self.bot.send_voice(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
await self.bot.send_voice(user_id, Wecom_event.agent_id, content['media_id'])
|
||||||
elif content['type'] == 'file':
|
elif content['type'] == 'file':
|
||||||
await self.bot.send_file(fixed_user_id, Wecom_event.agent_id, content['media_id'])
|
await self.bot.send_file(user_id, Wecom_event.agent_id, content['media_id'])
|
||||||
|
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
||||||
@@ -287,7 +288,7 @@ class WecomAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def on_message(event: WecomEvent):
|
async def on_message(event: WecomEvent):
|
||||||
self.bot_account_id = event.receiver_id
|
self.bot_account_id = event.receiver_id
|
||||||
try:
|
try:
|
||||||
return await callback(await self.event_converter.target2yiri(event), self)
|
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in wecom callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,38 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: WeCom
|
en_US: WeCom
|
||||||
zh_Hans: 企业微信
|
zh_Hans: 企业微信
|
||||||
|
zh_Hant: 企業微信
|
||||||
description:
|
description:
|
||||||
en_US: WeCom Adapter
|
en_US: WeCom Adapter
|
||||||
zh_Hans: 企业微信适配器,请查看文档了解使用方式
|
zh_Hans: 企业微信内部机器人,请查看文档了解使用方式
|
||||||
|
zh_Hant: 企業微信內部機器人,請查看文件了解使用方式
|
||||||
icon: wecom.png
|
icon: wecom.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/wecom
|
||||||
|
en: https://link.langbot.app/en/platforms/wecom
|
||||||
|
ja: https://link.langbot.app/ja/platforms/wecom
|
||||||
config:
|
config:
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your WeCom app's webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到企业微信应用的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到企業微信應用的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: corpid
|
- name: corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
zh_Hans: 企业ID
|
zh_Hans: 企业ID
|
||||||
|
zh_Hant: 企業ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +44,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Secret
|
en_US: Secret
|
||||||
zh_Hans: 密钥 (Secret)
|
zh_Hans: 密钥 (Secret)
|
||||||
|
zh_Hant: 密鑰 (Secret)
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +52,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌 (Token)
|
zh_Hans: 令牌 (Token)
|
||||||
|
zh_Hant: 令牌 (Token)
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -36,13 +60,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||||
type: string
|
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
|
||||||
required: true
|
|
||||||
default: ""
|
|
||||||
- name: contacts_secret
|
|
||||||
label:
|
|
||||||
en_US: Contacts Secret
|
|
||||||
zh_Hans: 通讯录密钥
|
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -50,9 +68,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: API Base URL
|
en_US: API Base URL
|
||||||
zh_Hans: API 基础 URL
|
zh_Hans: API 基础 URL
|
||||||
|
zh_Hant: API 基礎 URL
|
||||||
description:
|
description:
|
||||||
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
||||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档填写此项
|
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档填写此项
|
||||||
|
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取企業微信 API,可根據文件填寫此項
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
|
|||||||
from ..logger import EventLogger
|
from ..logger import EventLogger
|
||||||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
|
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
|
||||||
|
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
||||||
|
|
||||||
|
|
||||||
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@@ -23,14 +24,18 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
return content
|
return content
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomBotEvent):
|
async def target2yiri(event: WecomBotEvent, bot_name: str = ''):
|
||||||
yiri_msg_list = []
|
yiri_msg_list = []
|
||||||
if event.type == 'group':
|
if event.type == 'group':
|
||||||
yiri_msg_list.append(platform_message.At(target=event.ai_bot_id))
|
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.Source(id=event.message_id, time=datetime.datetime.now()))
|
||||||
|
|
||||||
if event.content:
|
if event.content:
|
||||||
yiri_msg_list.append(platform_message.Plain(text=event.content))
|
content = event.content
|
||||||
|
if bot_name:
|
||||||
|
content = content.replace(f'@{bot_name}', '').strip()
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=content))
|
||||||
|
|
||||||
images = []
|
images = []
|
||||||
if event.images:
|
if event.images:
|
||||||
@@ -133,13 +138,15 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
|
|
||||||
|
|
||||||
class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
|
def __init__(self, bot_name: str = ''):
|
||||||
|
self.bot_name = bot_name
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(event: platform_events.MessageEvent):
|
async def yiri2target(event: platform_events.MessageEvent):
|
||||||
return event.source_platform_object
|
return event.source_platform_object
|
||||||
|
|
||||||
@staticmethod
|
async def target2yiri(self, event: WecomBotEvent):
|
||||||
async def target2yiri(event: WecomBotEvent):
|
message_chain = await WecomBotMessageConverter.target2yiri(event, bot_name=self.bot_name)
|
||||||
message_chain = await WecomBotMessageConverter.target2yiri(event)
|
|
||||||
if event.type == 'single':
|
if event.type == 'single':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
@@ -176,34 +183,53 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
|
|
||||||
|
|
||||||
class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
bot: WecomBotClient
|
bot: typing.Union[WecomBotClient, WecomBotWsClient]
|
||||||
bot_account_id: str
|
bot_account_id: str
|
||||||
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
|
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
|
||||||
event_converter: WecomBotEventConverter = WecomBotEventConverter()
|
event_converter: WecomBotEventConverter
|
||||||
config: dict
|
config: dict
|
||||||
bot_uuid: str = None
|
bot_uuid: str = None
|
||||||
|
_ws_mode: bool = False
|
||||||
|
bot_name: str = ''
|
||||||
|
listeners: dict = {}
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId']
|
enable_webhook = config.get('enable-webhook', False)
|
||||||
missing_keys = [key for key in required_keys if key not in config]
|
bot_name = config.get('robot_name', '')
|
||||||
if missing_keys:
|
|
||||||
raise Exception(f'WecomBot 缺少配置项: {missing_keys}')
|
|
||||||
|
|
||||||
bot = WecomBotClient(
|
if not enable_webhook:
|
||||||
Token=config['Token'],
|
bot = WecomBotWsClient(
|
||||||
EnCodingAESKey=config['EncodingAESKey'],
|
bot_id=config['BotId'],
|
||||||
Corpid=config['Corpid'],
|
secret=config['Secret'],
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=True,
|
encoding_aes_key=config.get('EncodingAESKey', ''),
|
||||||
)
|
)
|
||||||
bot_account_id = config['BotId']
|
else:
|
||||||
|
# Webhook callback mode
|
||||||
|
required_keys = ['Token', 'EncodingAESKey', 'Corpid']
|
||||||
|
missing_keys = [key for key in required_keys if key not in config or not config[key]]
|
||||||
|
if missing_keys:
|
||||||
|
raise Exception(f'WecomBot webhook mode missing config: {missing_keys}')
|
||||||
|
|
||||||
|
bot = WecomBotClient(
|
||||||
|
Token=config['Token'],
|
||||||
|
EnCodingAESKey=config['EncodingAESKey'],
|
||||||
|
Corpid=config['Corpid'],
|
||||||
|
logger=logger,
|
||||||
|
unified_mode=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
bot_account_id = config.get('BotId', '')
|
||||||
|
event_converter = WecomBotEventConverter(bot_name=bot_name)
|
||||||
super().__init__(
|
super().__init__(
|
||||||
config=config,
|
config=config,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
bot=bot,
|
bot=bot,
|
||||||
bot_account_id=bot_account_id,
|
bot_account_id=bot_account_id,
|
||||||
|
bot_name=bot_name,
|
||||||
|
event_converter=event_converter,
|
||||||
)
|
)
|
||||||
|
self.listeners = {}
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
@@ -212,7 +238,17 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
):
|
):
|
||||||
content = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
await self.bot.set_message(message_source.source_platform_object.message_id, content)
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
|
||||||
|
if _ws_mode:
|
||||||
|
event = message_source.source_platform_object
|
||||||
|
req_id = event.get('req_id', '')
|
||||||
|
if req_id:
|
||||||
|
await self.bot.reply_text(req_id, content)
|
||||||
|
else:
|
||||||
|
await self.bot.set_message(event.message_id, content)
|
||||||
|
else:
|
||||||
|
await self.bot.set_message(message_source.source_platform_object.message_id, content)
|
||||||
|
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
@@ -222,44 +258,44 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
is_final: bool = False,
|
is_final: bool = False,
|
||||||
):
|
):
|
||||||
"""将流水线增量输出写入企业微信 stream 会话。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message_source: 流水线提供的原始消息事件。
|
|
||||||
bot_message: 当前片段对应的模型元信息(未使用)。
|
|
||||||
message: 需要回复的消息链。
|
|
||||||
quote_origin: 是否引用原消息(企业微信暂不支持)。
|
|
||||||
is_final: 标记当前片段是否为最终回复。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: 包含 `stream` 键,标识写入是否成功。
|
|
||||||
|
|
||||||
Example:
|
|
||||||
在流水线 `reply_message_chunk` 调用中自动触发,无需手动调用。
|
|
||||||
"""
|
|
||||||
# 转换为纯文本(智能机器人当前协议仅支持文本流)
|
|
||||||
content = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
msg_id = message_source.source_platform_object.message_id
|
msg_id = message_source.source_platform_object.message_id
|
||||||
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
|
||||||
# 将片段推送到 WecomBotClient 中的队列,返回值用于判断是否走降级逻辑
|
if _ws_mode:
|
||||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||||
if not success and is_final:
|
if not success and is_final:
|
||||||
# 未命中流式队列时使用旧有 set_message 兜底
|
event = message_source.source_platform_object
|
||||||
await self.bot.set_message(msg_id, content)
|
req_id = event.get('req_id', '')
|
||||||
return {'stream': success}
|
if req_id:
|
||||||
|
await self.bot.reply_text(req_id, content)
|
||||||
|
return {'stream': success}
|
||||||
|
else:
|
||||||
|
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||||
|
if not success and is_final:
|
||||||
|
await self.bot.set_message(msg_id, content)
|
||||||
|
return {'stream': success}
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
"""智能机器人侧默认开启流式能力。
|
"""Whether streaming output is enabled for this bot instance."""
|
||||||
|
return self.config.get('enable-stream-reply', True)
|
||||||
Returns:
|
|
||||||
bool: 恒定返回 True。
|
|
||||||
|
|
||||||
Example:
|
|
||||||
流水线执行阶段会调用此方法以确认是否启用流式。"""
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def send_message(self, target_type, target_id, message):
|
async def send_message(self, target_type, target_id, message):
|
||||||
pass
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
if _ws_mode:
|
||||||
|
content = await self.message_converter.yiri2target(message)
|
||||||
|
await self.bot.send_message(target_id, content)
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def on_message(self, event: WecomBotEvent):
|
||||||
|
try:
|
||||||
|
lb_event = await self.event_converter.target2yiri(event)
|
||||||
|
if lb_event:
|
||||||
|
await self.listeners[type(lb_event)](lb_event, self)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in wecombot callback: {traceback.format_exc()}')
|
||||||
|
print(traceback.format_exc())
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
@@ -268,18 +304,16 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||||
],
|
],
|
||||||
):
|
):
|
||||||
async def on_message(event: WecomBotEvent):
|
self.listeners[event_type] = callback
|
||||||
try:
|
|
||||||
return await callback(await self.event_converter.target2yiri(event), self)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Error in wecombot callback: {traceback.format_exc()}')
|
|
||||||
print(traceback.format_exc())
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if event_type == platform_events.FriendMessage:
|
if event_type == platform_events.FriendMessage:
|
||||||
self.bot.on_message('single')(on_message)
|
self.bot.on_message('single')(self.on_message)
|
||||||
elif event_type == platform_events.GroupMessage:
|
elif event_type == platform_events.GroupMessage:
|
||||||
self.bot.on_message('group')(on_message)
|
self.bot.on_message('group')(self.on_message)
|
||||||
|
elif event_type == platform_events.FeedbackEvent:
|
||||||
|
if hasattr(self.bot, 'on_feedback'):
|
||||||
|
self.bot.on_feedback()(self._on_feedback)
|
||||||
except Exception:
|
except Exception:
|
||||||
print(traceback.format_exc())
|
print(traceback.format_exc())
|
||||||
|
|
||||||
@@ -287,30 +321,68 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||||
self.bot_uuid = bot_uuid
|
self.bot_uuid = bot_uuid
|
||||||
|
|
||||||
|
async def _on_feedback(self, **kwargs):
|
||||||
|
"""Handle feedback event from WeChat Work AI Bot SDK and dispatch as FeedbackEvent."""
|
||||||
|
try:
|
||||||
|
feedback_id = kwargs.get('feedback_id', '')
|
||||||
|
feedback_type = kwargs.get('feedback_type', 0)
|
||||||
|
feedback_content = kwargs.get('feedback_content', '') or None
|
||||||
|
inaccurate_reasons = kwargs.get('inaccurate_reasons', []) or None
|
||||||
|
session = kwargs.get('session')
|
||||||
|
|
||||||
|
session_id = None
|
||||||
|
user_id = None
|
||||||
|
message_id = None
|
||||||
|
stream_id = None
|
||||||
|
if session:
|
||||||
|
if session.chat_id:
|
||||||
|
session_id = f'group_{session.chat_id}'
|
||||||
|
elif session.user_id:
|
||||||
|
session_id = f'person_{session.user_id}'
|
||||||
|
user_id = session.user_id
|
||||||
|
message_id = session.msg_id
|
||||||
|
stream_id = session.stream_id
|
||||||
|
|
||||||
|
event = platform_events.FeedbackEvent(
|
||||||
|
feedback_id=feedback_id,
|
||||||
|
feedback_type=feedback_type,
|
||||||
|
feedback_content=feedback_content,
|
||||||
|
inaccurate_reasons=inaccurate_reasons,
|
||||||
|
user_id=user_id,
|
||||||
|
session_id=session_id,
|
||||||
|
message_id=message_id,
|
||||||
|
stream_id=stream_id,
|
||||||
|
source_platform_object=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
if platform_events.FeedbackEvent in self.listeners:
|
||||||
|
await self.listeners[platform_events.FeedbackEvent](event, self)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in wecombot feedback callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
||||||
"""处理统一 webhook 请求。
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
if _ws_mode:
|
||||||
Args:
|
return None
|
||||||
bot_uuid: Bot 的 UUID
|
|
||||||
path: 子路径(如果有的话)
|
|
||||||
request: Quart Request 对象
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
响应数据
|
|
||||||
"""
|
|
||||||
return await self.bot.handle_unified_webhook(request)
|
return await self.bot.handle_unified_webhook(request)
|
||||||
|
|
||||||
async def run_async(self):
|
async def run_async(self):
|
||||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
# 保持运行但不启动独立端口
|
if _ws_mode:
|
||||||
|
await self.bot.connect()
|
||||||
|
else:
|
||||||
|
|
||||||
async def keep_alive():
|
async def keep_alive():
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
await keep_alive()
|
await keep_alive()
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
|
_ws_mode = not self.config.get('enable-webhook', False)
|
||||||
|
if _ws_mode:
|
||||||
|
await self.bot.disconnect()
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def unregister_listener(
|
async def unregister_listener(
|
||||||
|
|||||||
@@ -5,41 +5,125 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: WeComBot
|
en_US: WeComBot
|
||||||
zh_Hans: 企业微信智能机器人
|
zh_Hans: 企业微信智能机器人
|
||||||
|
zh_Hant: 企業微信智慧機器人
|
||||||
description:
|
description:
|
||||||
en_US: WeComBot Adapter
|
en_US: WeComBot Adapter
|
||||||
zh_Hans: 企业微信智能机器人适配器,请查看文档了解使用方式
|
zh_Hans: 企业微信智能机器人,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
|
||||||
|
zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
|
||||||
icon: wecombot.png
|
icon: wecombot.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/wecombot
|
||||||
|
en: https://link.langbot.app/en/platforms/wecombot
|
||||||
|
ja: https://link.langbot.app/ja/platforms/wecombot
|
||||||
config:
|
config:
|
||||||
|
- name: BotId
|
||||||
|
label:
|
||||||
|
en_US: BotId
|
||||||
|
zh_Hans: 机器人ID (BotId)
|
||||||
|
zh_Hant: 機器人ID (BotId)
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
|
- name: robot_name
|
||||||
|
label:
|
||||||
|
en_US: Robot Name
|
||||||
|
zh_Hans: 机器人名称
|
||||||
|
zh_Hant: 機器人名稱
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
|
- name: enable-webhook
|
||||||
|
label:
|
||||||
|
en_US: Enable Webhook Mode
|
||||||
|
zh_Hans: 启用Webhook模式
|
||||||
|
zh_Hant: 啟用 Webhook 模式
|
||||||
|
description:
|
||||||
|
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
||||||
|
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
||||||
|
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WS 長連線模式
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your WeComBot webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到企业微信智能机器人的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到企業微信智慧機器人的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
show_if:
|
||||||
|
field: enable-webhook
|
||||||
|
operator: eq
|
||||||
|
value: true
|
||||||
|
- name: Secret
|
||||||
|
label:
|
||||||
|
en_US: Secret
|
||||||
|
zh_Hans: 机器人密钥 (Secret)
|
||||||
|
zh_Hant: 機器人密鑰 (Secret)
|
||||||
|
description:
|
||||||
|
en_US: Required for WebSocket long connection mode
|
||||||
|
zh_Hans: 使用 WS 长连接模式时必填
|
||||||
|
zh_Hant: 使用 WS 長連線模式時必填
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: Corpid
|
- name: Corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
zh_Hans: 企业ID
|
zh_Hans: 企业ID
|
||||||
|
zh_Hant: 企業ID
|
||||||
|
description:
|
||||||
|
en_US: Required for Webhook mode
|
||||||
|
zh_Hans: 使用 Webhook 模式时必填
|
||||||
|
zh_Hant: 使用 Webhook 模式時必填
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
- name: Token
|
- name: Token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌 (Token)
|
zh_Hans: 令牌 (Token)
|
||||||
|
zh_Hant: 令牌 (Token)
|
||||||
|
description:
|
||||||
|
en_US: Required for Webhook mode
|
||||||
|
zh_Hans: 使用 Webhook 模式时必填
|
||||||
|
zh_Hant: 使用 Webhook 模式時必填
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
- name: EncodingAESKey
|
- name: EncodingAESKey
|
||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||||
type: string
|
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
|
||||||
required: true
|
description:
|
||||||
default: ""
|
en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption)
|
||||||
- name: BotId
|
zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选(用于文件解密)
|
||||||
label:
|
zh_Hant: 使用 Webhook 模式時必填。WebSocket 模式下可選(用於檔案解密)
|
||||||
en_US: BotId
|
|
||||||
zh_Hans: 机器人ID
|
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
|
- name: enable-stream-reply
|
||||||
|
label:
|
||||||
|
en_US: Enable Stream Reply
|
||||||
|
zh_Hans: 启用流式回复
|
||||||
|
zh_Hant: 啟用串流回覆
|
||||||
|
description:
|
||||||
|
en_US: If enabled, the bot will use streaming mode to reply messages
|
||||||
|
zh_Hans: 如果启用,机器人将使用流式模式回复消息
|
||||||
|
zh_Hant: 如果啟用,機器人將使用串流模式回覆訊息
|
||||||
|
type: boolean
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./wecombot.py
|
path: ./wecombot.py
|
||||||
attr: WecomBotAdapter
|
attr: WecomBotAdapter
|
||||||
|
|||||||
@@ -81,22 +81,33 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return event.source_platform_object
|
return event.source_platform_object
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomCSEvent):
|
async def target2yiri(event: WecomCSEvent, bot: WecomCSClient = None):
|
||||||
"""
|
"""
|
||||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event (WecomEvent): 企业微信客服事件。
|
event (WecomEvent): 企业微信客服事件。
|
||||||
|
bot (WecomCSClient): 企业微信客服客户端,用于获取用户信息。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
||||||
"""
|
"""
|
||||||
|
# Try to get customer nickname from WeChat API
|
||||||
|
nickname = str(event.user_id)
|
||||||
|
if bot and event.user_id:
|
||||||
|
try:
|
||||||
|
customer_info = await bot.get_customer_info(event.user_id)
|
||||||
|
if customer_info and customer_info.get('nickname'):
|
||||||
|
nickname = customer_info.get('nickname')
|
||||||
|
except Exception:
|
||||||
|
pass # Fall back to user_id as nickname
|
||||||
|
|
||||||
# 转换消息链
|
# 转换消息链
|
||||||
if event.type == 'text':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=str(event.user_id),
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -106,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
elif event.type == 'image':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=str(event.user_id),
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -187,7 +198,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def on_message(event: WecomCSEvent):
|
async def on_message(event: WecomCSEvent):
|
||||||
self.bot_account_id = event.receiver_id
|
self.bot_account_id = event.receiver_id
|
||||||
try:
|
try:
|
||||||
return await callback(await self.event_converter.target2yiri(event), self)
|
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -5,16 +5,37 @@ metadata:
|
|||||||
label:
|
label:
|
||||||
en_US: WeComCustomerService
|
en_US: WeComCustomerService
|
||||||
zh_Hans: 企业微信客服
|
zh_Hans: 企业微信客服
|
||||||
|
zh_Hant: 企業微信客服
|
||||||
description:
|
description:
|
||||||
en_US: WeComCSAdapter
|
en_US: WeComCSAdapter
|
||||||
zh_Hans: 企业微信客服适配器
|
zh_Hans: 企业微信对外客服机器人,需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||||
|
zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||||
icon: wecom.png
|
icon: wecom.png
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- china
|
||||||
|
help_links:
|
||||||
|
zh: https://link.langbot.app/zh/platforms/wecomcs
|
||||||
|
en: https://link.langbot.app/en/platforms/wecomcs
|
||||||
|
ja: https://link.langbot.app/ja/platforms/wecomcs
|
||||||
config:
|
config:
|
||||||
|
- name: webhook_url
|
||||||
|
label:
|
||||||
|
en_US: Webhook Callback URL
|
||||||
|
zh_Hans: Webhook 回调地址
|
||||||
|
zh_Hant: Webhook 回調地址
|
||||||
|
description:
|
||||||
|
en_US: Copy this URL and paste it into your WeCom Customer Service webhook configuration
|
||||||
|
zh_Hans: 复制此地址并粘贴到企业微信客服的 Webhook 配置中
|
||||||
|
zh_Hant: 複製此地址並貼到企業微信客服的 Webhook 設定中
|
||||||
|
type: webhook-url
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: corpid
|
- name: corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
zh_Hans: 企业ID
|
zh_Hans: 企业ID
|
||||||
|
zh_Hant: 企業ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -22,6 +43,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Secret
|
en_US: Secret
|
||||||
zh_Hans: 密钥
|
zh_Hans: 密钥
|
||||||
|
zh_Hant: 密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -29,6 +51,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌
|
zh_Hans: 令牌
|
||||||
|
zh_Hant: 令牌
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -36,6 +59,7 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥
|
zh_Hans: 消息加解密密钥
|
||||||
|
zh_Hant: 訊息加解密密鑰
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
default: ""
|
default: ""
|
||||||
@@ -43,9 +67,11 @@ spec:
|
|||||||
label:
|
label:
|
||||||
en_US: API Base URL
|
en_US: API Base URL
|
||||||
zh_Hans: API 基础 URL
|
zh_Hans: API 基础 URL
|
||||||
|
zh_Hant: API 基礎 URL
|
||||||
description:
|
description:
|
||||||
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
en_US: API Base URL, used for accessing the WeCom API. If you are deploying in an internal network environment and accessing the WeCom Customer Service API through a reverse proxy, please fill in this item according to the documentation.
|
||||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档修改此项
|
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档修改此项
|
||||||
|
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取企業微信 API,可根據文件修改此項
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import io
|
||||||
|
import time
|
||||||
|
import zipfile
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import typing
|
import typing
|
||||||
import os
|
import os
|
||||||
@@ -192,6 +195,30 @@ class PluginRuntimeConnector:
|
|||||||
|
|
||||||
return await self.handler.ping()
|
return await self.handler.ping()
|
||||||
|
|
||||||
|
def _extract_deps_metadata(
|
||||||
|
self,
|
||||||
|
file_bytes: bytes,
|
||||||
|
task_context: taskmgr.TaskContext | None,
|
||||||
|
):
|
||||||
|
"""Extract dependency count from requirements.txt inside plugin zip."""
|
||||||
|
if task_context is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
|
||||||
|
for name in zf.namelist():
|
||||||
|
if name.endswith('requirements.txt'):
|
||||||
|
content = zf.read(name).decode('utf-8', errors='ignore')
|
||||||
|
deps = [
|
||||||
|
line.strip()
|
||||||
|
for line in content.splitlines()
|
||||||
|
if line.strip() and not line.strip().startswith('#')
|
||||||
|
]
|
||||||
|
task_context.metadata['deps_total'] = len(deps)
|
||||||
|
task_context.metadata['deps_list'] = deps
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
async def install_plugin(
|
async def install_plugin(
|
||||||
self,
|
self,
|
||||||
install_source: PluginInstallSource,
|
install_source: PluginInstallSource,
|
||||||
@@ -201,23 +228,44 @@ class PluginRuntimeConnector:
|
|||||||
if install_source == PluginInstallSource.LOCAL:
|
if install_source == PluginInstallSource.LOCAL:
|
||||||
# transfer file before install
|
# transfer file before install
|
||||||
file_bytes = install_info['plugin_file']
|
file_bytes = install_info['plugin_file']
|
||||||
|
self._extract_deps_metadata(file_bytes, task_context)
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
del install_info['plugin_file']
|
del install_info['plugin_file']
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
elif install_source == PluginInstallSource.GITHUB:
|
elif install_source == PluginInstallSource.GITHUB:
|
||||||
# download and transfer file
|
# download and transfer file with streaming progress
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
trust_env=True,
|
trust_env=True,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
timeout=20,
|
timeout=60,
|
||||||
) as client:
|
) as client:
|
||||||
response = await client.get(
|
async with client.stream('GET', install_info['asset_url']) as response:
|
||||||
install_info['asset_url'],
|
response.raise_for_status()
|
||||||
)
|
total = int(response.headers.get('content-length', 0))
|
||||||
response.raise_for_status()
|
downloaded = 0
|
||||||
file_bytes = response.content
|
chunks: list[bytes] = []
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
if task_context is not None:
|
||||||
|
task_context.set_current_action('downloading plugin package')
|
||||||
|
task_context.metadata['download_total'] = total
|
||||||
|
task_context.metadata['download_current'] = 0
|
||||||
|
task_context.metadata['download_speed'] = 0
|
||||||
|
|
||||||
|
async for chunk in response.aiter_bytes(chunk_size=8192):
|
||||||
|
chunks.append(chunk)
|
||||||
|
downloaded += len(chunk)
|
||||||
|
|
||||||
|
if task_context is not None:
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
task_context.metadata['download_current'] = downloaded
|
||||||
|
task_context.metadata['download_total'] = total
|
||||||
|
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
|
||||||
|
|
||||||
|
file_bytes = b''.join(chunks)
|
||||||
|
self._extract_deps_metadata(file_bytes, task_context)
|
||||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
@@ -236,6 +284,11 @@ class PluginRuntimeConnector:
|
|||||||
if task_context is not None:
|
if task_context is not None:
|
||||||
task_context.trace(trace)
|
task_context.trace(trace)
|
||||||
|
|
||||||
|
# Forward structured metadata from runtime
|
||||||
|
metadata = ret.get('metadata', None)
|
||||||
|
if metadata is not None and task_context is not None:
|
||||||
|
task_context.metadata.update(metadata)
|
||||||
|
|
||||||
async def upgrade_plugin(
|
async def upgrade_plugin(
|
||||||
self,
|
self,
|
||||||
plugin_author: str,
|
plugin_author: str,
|
||||||
|
|||||||
@@ -314,11 +314,11 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
|
|
||||||
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
|
@self.action(PluginToRuntimeAction.GET_LLM_MODELS)
|
||||||
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
|
async def get_llm_models(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
"""Get llm models"""
|
"""Get llm models, returns list of UUID strings"""
|
||||||
llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False)
|
llm_models = await self.ap.llm_model_service.get_llm_models(include_secret=False)
|
||||||
return handler.ActionResponse.success(
|
return handler.ActionResponse.success(
|
||||||
data={
|
data={
|
||||||
'llm_models': llm_models,
|
'llm_models': [m['uuid'] for m in llm_models],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -337,7 +337,14 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
)
|
)
|
||||||
|
|
||||||
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
|
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
|
||||||
funcs_obj = [resource_tool.LLMTool.model_validate(func) for func in funcs]
|
|
||||||
|
# The func field is excluded during model_dump() in plugin side (marked as exclude=True),
|
||||||
|
# but it's a required field for LLMTool validation. We need to provide a placeholder
|
||||||
|
# function when reconstructing the LLMTool objects from serialized data.
|
||||||
|
async def _placeholder_func(**kwargs):
|
||||||
|
pass
|
||||||
|
|
||||||
|
funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs]
|
||||||
|
|
||||||
result = await llm_model.provider.invoke_llm(
|
result = await llm_model.provider.invoke_llm(
|
||||||
query=None,
|
query=None,
|
||||||
@@ -524,6 +531,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
filters = data.get('filters')
|
filters = data.get('filters')
|
||||||
search_type = data.get('search_type', 'vector')
|
search_type = data.get('search_type', 'vector')
|
||||||
query_text = data.get('query_text', '')
|
query_text = data.get('query_text', '')
|
||||||
|
vector_weight = data.get('vector_weight')
|
||||||
try:
|
try:
|
||||||
results = await self.ap.rag_runtime_service.vector_search(
|
results = await self.ap.rag_runtime_service.vector_search(
|
||||||
collection_id,
|
collection_id,
|
||||||
@@ -532,6 +540,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
filters,
|
filters,
|
||||||
search_type,
|
search_type,
|
||||||
query_text,
|
query_text,
|
||||||
|
vector_weight=vector_weight,
|
||||||
)
|
)
|
||||||
return handler.ActionResponse.success(data={'results': results})
|
return handler.ActionResponse.success(data={'results': results})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -548,6 +557,18 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)
|
return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)
|
||||||
|
|
||||||
|
@self.action(PluginToRuntimeAction.VECTOR_LIST)
|
||||||
|
async def vector_list(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
|
collection_id = data['collection_id']
|
||||||
|
filters = data.get('filters')
|
||||||
|
limit = data.get('limit', 20)
|
||||||
|
offset = data.get('offset', 0)
|
||||||
|
try:
|
||||||
|
items, total = await self.ap.rag_runtime_service.vector_list(collection_id, filters, limit, offset)
|
||||||
|
return handler.ActionResponse.success(data={'items': items, 'total': total})
|
||||||
|
except Exception as e:
|
||||||
|
return _make_rag_error_response(e, 'VectorStoreError', collection_id=collection_id)
|
||||||
|
|
||||||
@self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM)
|
@self.action(PluginToRuntimeAction.GET_KNOWLEDEGE_FILE_STREAM)
|
||||||
async def get_knowledge_file_stream(data: dict[str, Any]) -> handler.ActionResponse:
|
async def get_knowledge_file_stream(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
storage_path = data['storage_path']
|
storage_path = data['storage_path']
|
||||||
@@ -558,6 +579,16 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _make_rag_error_response(e, 'FileServiceError', storage_path=storage_path)
|
return _make_rag_error_response(e, 'FileServiceError', storage_path=storage_path)
|
||||||
|
|
||||||
|
@self.action(PluginToRuntimeAction.LIST_PARSERS)
|
||||||
|
async def list_parsers(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
|
"""Plugin requests host to list available parser plugins."""
|
||||||
|
mime_type = data.get('mime_type')
|
||||||
|
try:
|
||||||
|
parsers = await self.ap.knowledge_service.list_parsers(mime_type)
|
||||||
|
return handler.ActionResponse.success(data={'parsers': parsers})
|
||||||
|
except Exception as e:
|
||||||
|
return _make_rag_error_response(e, 'ParserDiscoveryError', mime_type=mime_type)
|
||||||
|
|
||||||
@self.action(PluginToRuntimeAction.INVOKE_PARSER)
|
@self.action(PluginToRuntimeAction.INVOKE_PARSER)
|
||||||
async def invoke_parser(data: dict[str, Any]) -> handler.ActionResponse:
|
async def invoke_parser(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
"""Plugin requests host to invoke a parser plugin."""
|
"""Plugin requests host to invoke a parser plugin."""
|
||||||
@@ -582,6 +613,139 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _make_rag_error_response(e, 'ParserError')
|
return _make_rag_error_response(e, 'ParserError')
|
||||||
|
|
||||||
|
# ================= Knowledge Base Query APIs =================
|
||||||
|
|
||||||
|
@self.action(PluginToRuntimeAction.LIST_KNOWLEDGE_BASES)
|
||||||
|
async def list_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
|
"""List all knowledge bases available in the LangBot instance (unrestricted)."""
|
||||||
|
knowledge_bases = []
|
||||||
|
for kb_uuid, kb in self.ap.rag_mgr.knowledge_bases.items():
|
||||||
|
knowledge_bases.append(
|
||||||
|
{
|
||||||
|
'uuid': kb.get_uuid(),
|
||||||
|
'name': kb.get_name(),
|
||||||
|
'description': kb.knowledge_base_entity.description or '',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})
|
||||||
|
|
||||||
|
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE)
|
||||||
|
async def retrieve_knowledge(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
|
"""Retrieve documents from any knowledge base (unrestricted)."""
|
||||||
|
kb_id = data['kb_id']
|
||||||
|
query_text = data['query_text']
|
||||||
|
top_k = data.get('top_k', 5)
|
||||||
|
filters = data.get('filters', {})
|
||||||
|
|
||||||
|
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
|
||||||
|
if not kb:
|
||||||
|
return handler.ActionResponse.error(
|
||||||
|
message=f'Knowledge base {kb_id} not found',
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = await kb.retrieve(
|
||||||
|
query_text,
|
||||||
|
settings={
|
||||||
|
'top_k': top_k,
|
||||||
|
'filters': filters,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
results = [entry.model_dump(mode='json') for entry in entries]
|
||||||
|
return handler.ActionResponse.success(data={'results': results})
|
||||||
|
except Exception as e:
|
||||||
|
return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)
|
||||||
|
|
||||||
|
@self.action(PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES)
|
||||||
|
async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
|
"""List knowledge bases configured for the current query's pipeline."""
|
||||||
|
query_id = data['query_id']
|
||||||
|
|
||||||
|
if query_id not in self.ap.query_pool.cached_queries:
|
||||||
|
return handler.ActionResponse.error(
|
||||||
|
message=f'Query with query_id {query_id} not found',
|
||||||
|
)
|
||||||
|
|
||||||
|
query = self.ap.query_pool.cached_queries[query_id]
|
||||||
|
|
||||||
|
kb_uuids = []
|
||||||
|
if query.pipeline_config:
|
||||||
|
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
|
||||||
|
kb_uuids = local_agent_config.get('knowledge-bases', [])
|
||||||
|
# Backward compatibility
|
||||||
|
if not kb_uuids:
|
||||||
|
old_kb_uuid = local_agent_config.get('knowledge-base', '')
|
||||||
|
if old_kb_uuid and old_kb_uuid != '__none__':
|
||||||
|
kb_uuids = [old_kb_uuid]
|
||||||
|
|
||||||
|
knowledge_bases = []
|
||||||
|
for kb_uuid in kb_uuids:
|
||||||
|
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||||
|
if kb:
|
||||||
|
knowledge_bases.append(
|
||||||
|
{
|
||||||
|
'uuid': kb.get_uuid(),
|
||||||
|
'name': kb.get_name(),
|
||||||
|
'description': kb.knowledge_base_entity.description or '',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})
|
||||||
|
|
||||||
|
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE)
|
||||||
|
async def retrieve_knowledge_base(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
|
"""Retrieve documents from a knowledge base within the pipeline's scope."""
|
||||||
|
query_id = data['query_id']
|
||||||
|
kb_id = data['kb_id']
|
||||||
|
query_text = data['query_text']
|
||||||
|
top_k = data.get('top_k', 5)
|
||||||
|
filters = data.get('filters', {})
|
||||||
|
|
||||||
|
if query_id not in self.ap.query_pool.cached_queries:
|
||||||
|
return handler.ActionResponse.error(
|
||||||
|
message=f'Query with query_id {query_id} not found',
|
||||||
|
)
|
||||||
|
|
||||||
|
query = self.ap.query_pool.cached_queries[query_id]
|
||||||
|
|
||||||
|
# Validate kb_id is in pipeline's allowed list
|
||||||
|
allowed_kb_uuids = []
|
||||||
|
if query.pipeline_config:
|
||||||
|
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
|
||||||
|
allowed_kb_uuids = local_agent_config.get('knowledge-bases', [])
|
||||||
|
if not allowed_kb_uuids:
|
||||||
|
old_kb_uuid = local_agent_config.get('knowledge-base', '')
|
||||||
|
if old_kb_uuid and old_kb_uuid != '__none__':
|
||||||
|
allowed_kb_uuids = [old_kb_uuid]
|
||||||
|
|
||||||
|
if kb_id not in allowed_kb_uuids:
|
||||||
|
return handler.ActionResponse.error(
|
||||||
|
message=f'Knowledge base {kb_id} is not configured for this pipeline',
|
||||||
|
)
|
||||||
|
|
||||||
|
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
|
||||||
|
if not kb:
|
||||||
|
return handler.ActionResponse.error(
|
||||||
|
message=f'Knowledge base {kb_id} not found',
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
session_name = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||||
|
entries = await kb.retrieve(
|
||||||
|
query_text,
|
||||||
|
settings={
|
||||||
|
'top_k': top_k,
|
||||||
|
'filters': filters,
|
||||||
|
'session_name': session_name,
|
||||||
|
'bot_uuid': query.bot_uuid or '',
|
||||||
|
'sender_id': str(query.sender_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
results = [entry.model_dump(mode='json') for entry in entries]
|
||||||
|
return handler.ActionResponse.success(data={'results': results})
|
||||||
|
except Exception as e:
|
||||||
|
return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)
|
||||||
|
|
||||||
@self.action(CommonAction.PING)
|
@self.action(CommonAction.PING)
|
||||||
async def ping(data: dict[str, Any]) -> handler.ActionResponse:
|
async def ping(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
"""Ping"""
|
"""Ping"""
|
||||||
@@ -888,7 +1052,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
result = await self.call_action(
|
result = await self.call_action(
|
||||||
LangBotToRuntimeAction.RAG_INGEST_DOCUMENT,
|
LangBotToRuntimeAction.RAG_INGEST_DOCUMENT,
|
||||||
{'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data},
|
{'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data},
|
||||||
timeout=300, # Ingestion can be slow
|
timeout=1200, # Ingestion can be slow for large documents
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -288,10 +288,10 @@ class AnthropicMessages(requester.ProviderAPIRequester):
|
|||||||
think_started = False
|
think_started = False
|
||||||
think_ended = False
|
think_ended = False
|
||||||
finish_reason = False
|
finish_reason = False
|
||||||
content = ''
|
|
||||||
tool_name = ''
|
tool_name = ''
|
||||||
tool_id = ''
|
tool_id = ''
|
||||||
async for chunk in await self.client.messages.create(**args):
|
async for chunk in await self.client.messages.create(**args):
|
||||||
|
content = ''
|
||||||
tool_call = {'id': None, 'function': {'name': None, 'arguments': None}, 'type': 'function'}
|
tool_call = {'id': None, 'function': {'name': None, 'arguments': None}, 'type': 'function'}
|
||||||
if isinstance(
|
if isinstance(
|
||||||
chunk, anthropic.types.raw_content_block_start_event.RawContentBlockStartEvent
|
chunk, anthropic.types.raw_content_block_start_event.RawContentBlockStartEvent
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
|||||||
is_final = False
|
is_final = False
|
||||||
think_start = False
|
think_start = False
|
||||||
think_end = False
|
think_end = False
|
||||||
|
yielded_final = False
|
||||||
|
|
||||||
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||||
|
|
||||||
@@ -493,13 +494,19 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
|||||||
if answer:
|
if answer:
|
||||||
basic_mode_pending_chunk = answer
|
basic_mode_pending_chunk = answer
|
||||||
|
|
||||||
if (is_final or message_idx % 8 == 0) and (basic_mode_pending_chunk != '' or is_final):
|
if (
|
||||||
|
not yielded_final
|
||||||
|
and (is_final or message_idx % 8 == 0)
|
||||||
|
and (basic_mode_pending_chunk != '' or is_final)
|
||||||
|
):
|
||||||
# content, _ = self._process_thinking_content(basic_mode_pending_chunk)
|
# content, _ = self._process_thinking_content(basic_mode_pending_chunk)
|
||||||
yield provider_message.MessageChunk(
|
yield provider_message.MessageChunk(
|
||||||
role='assistant',
|
role='assistant',
|
||||||
content=basic_mode_pending_chunk,
|
content=basic_mode_pending_chunk,
|
||||||
is_final=is_final,
|
is_final=is_final,
|
||||||
)
|
)
|
||||||
|
if is_final:
|
||||||
|
yielded_final = True
|
||||||
|
|
||||||
if chunk is None:
|
if chunk is None:
|
||||||
raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置')
|
raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import copy
|
import copy
|
||||||
import typing
|
import typing
|
||||||
from .. import runner
|
from .. import runner
|
||||||
|
from ..modelmgr import requester as modelmgr_requester
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
import langbot_plugin.api.entities.builtin.rag.context as rag_context
|
import langbot_plugin.api.entities.builtin.rag.context as rag_context
|
||||||
@@ -26,29 +27,114 @@ Respond in the same language as the user's input.
|
|||||||
|
|
||||||
@runner.runner_class('local-agent')
|
@runner.runner_class('local-agent')
|
||||||
class LocalAgentRunner(runner.RequestRunner):
|
class LocalAgentRunner(runner.RequestRunner):
|
||||||
"""本地Agent请求运行器"""
|
"""Local agent request runner"""
|
||||||
|
|
||||||
class ToolCallTracker:
|
async def _get_model_candidates(
|
||||||
"""工具调用追踪器"""
|
self,
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
) -> list[modelmgr_requester.RuntimeLLMModel]:
|
||||||
|
"""Build ordered list of models to try: primary model + fallback models."""
|
||||||
|
candidates = []
|
||||||
|
|
||||||
def __init__(self):
|
# Primary model
|
||||||
self.active_calls: dict[str, dict] = {}
|
if query.use_llm_model_uuid:
|
||||||
self.completed_calls: list[provider_message.ToolCall] = []
|
try:
|
||||||
|
primary = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||||
|
candidates.append(primary)
|
||||||
|
except ValueError:
|
||||||
|
self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found')
|
||||||
|
|
||||||
|
# Fallback models
|
||||||
|
fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', [])
|
||||||
|
for fb_uuid in fallback_uuids:
|
||||||
|
try:
|
||||||
|
fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
|
||||||
|
candidates.append(fb_model)
|
||||||
|
except ValueError:
|
||||||
|
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
async def _invoke_with_fallback(
|
||||||
|
self,
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
candidates: list[modelmgr_requester.RuntimeLLMModel],
|
||||||
|
messages: list,
|
||||||
|
funcs: list,
|
||||||
|
remove_think: bool,
|
||||||
|
) -> tuple[provider_message.Message, modelmgr_requester.RuntimeLLMModel]:
|
||||||
|
"""Try non-streaming invocation with sequential fallback. Returns (message, model_used)."""
|
||||||
|
last_error = None
|
||||||
|
for model in candidates:
|
||||||
|
try:
|
||||||
|
msg = await model.provider.invoke_llm(
|
||||||
|
query,
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
funcs if model.model_entity.abilities.__contains__('func_call') else [],
|
||||||
|
extra_args=model.model_entity.extra_args,
|
||||||
|
remove_think=remove_think,
|
||||||
|
)
|
||||||
|
return msg, model
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
self.ap.logger.warning(f'Model {model.model_entity.name} failed: {e}, trying next fallback...')
|
||||||
|
raise last_error or RuntimeError('No model candidates available')
|
||||||
|
|
||||||
|
async def _invoke_stream_with_fallback(
|
||||||
|
self,
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
candidates: list[modelmgr_requester.RuntimeLLMModel],
|
||||||
|
messages: list,
|
||||||
|
funcs: list,
|
||||||
|
remove_think: bool,
|
||||||
|
) -> tuple[typing.AsyncGenerator, modelmgr_requester.RuntimeLLMModel]:
|
||||||
|
"""Try streaming invocation with sequential fallback. Returns (stream_generator, model_used).
|
||||||
|
|
||||||
|
Fallback is only possible before any chunks have been yielded to the client.
|
||||||
|
Once streaming starts, the model is committed.
|
||||||
|
"""
|
||||||
|
last_error = None
|
||||||
|
for model in candidates:
|
||||||
|
try:
|
||||||
|
stream = model.provider.invoke_llm_stream(
|
||||||
|
query,
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
funcs if model.model_entity.abilities.__contains__('func_call') else [],
|
||||||
|
extra_args=model.model_entity.extra_args,
|
||||||
|
remove_think=remove_think,
|
||||||
|
)
|
||||||
|
# Attempt to get the first chunk to verify the stream works
|
||||||
|
first_chunk = await stream.__anext__()
|
||||||
|
|
||||||
|
async def _chain_stream(first, rest):
|
||||||
|
yield first
|
||||||
|
async for chunk in rest:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return _chain_stream(first_chunk, stream), model
|
||||||
|
except StopAsyncIteration:
|
||||||
|
# Empty stream — treat as success (model returned nothing)
|
||||||
|
async def _empty_stream():
|
||||||
|
return
|
||||||
|
yield # make it a generator
|
||||||
|
|
||||||
|
return _empty_stream(), model
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
self.ap.logger.warning(f'Model {model.model_entity.name} stream failed: {e}, trying next fallback...')
|
||||||
|
raise last_error or RuntimeError('No model candidates available')
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
self, query: pipeline_query.Query
|
self, query: pipeline_query.Query
|
||||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||||
"""运行请求"""
|
"""Run request"""
|
||||||
pending_tool_calls = []
|
pending_tool_calls = []
|
||||||
|
|
||||||
# Get knowledge bases list (new field)
|
# Get knowledge bases list from query variables (set by PreProcessor,
|
||||||
kb_uuids = query.pipeline_config['ai']['local-agent'].get('knowledge-bases', [])
|
# may have been modified by plugins during PromptPreProcessing)
|
||||||
|
kb_uuids = query.variables.get('_knowledge_base_uuids', [])
|
||||||
# Fallback to old field for backward compatibility
|
|
||||||
if not kb_uuids:
|
|
||||||
old_kb_uuid = query.pipeline_config['ai']['local-agent'].get('knowledge-base', '')
|
|
||||||
if old_kb_uuid and old_kb_uuid != '__none__':
|
|
||||||
kb_uuids = [old_kb_uuid]
|
|
||||||
|
|
||||||
user_message = copy.deepcopy(query.user_message)
|
user_message = copy.deepcopy(query.user_message)
|
||||||
|
|
||||||
@@ -74,7 +160,14 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
|
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = await kb.retrieve(user_message_text)
|
result = await kb.retrieve(
|
||||||
|
user_message_text,
|
||||||
|
settings={
|
||||||
|
'bot_uuid': query.bot_uuid or '',
|
||||||
|
'sender_id': str(query.sender_id),
|
||||||
|
'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
all_results.extend(result)
|
all_results.extend(result)
|
||||||
@@ -113,51 +206,51 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think')
|
remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||||
|
|
||||||
use_llm_model = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
# Build ordered candidate list (primary + fallbacks)
|
||||||
|
candidates = await self._get_model_candidates(query)
|
||||||
|
if not candidates:
|
||||||
|
raise RuntimeError('No LLM model configured for local-agent runner')
|
||||||
|
|
||||||
self.ap.logger.debug(
|
self.ap.logger.debug(
|
||||||
f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}'
|
f'localagent req: query={query.query_id} req_messages={req_messages} '
|
||||||
|
f'candidates={[m.model_entity.name for m in candidates]}'
|
||||||
)
|
)
|
||||||
|
|
||||||
if not is_stream:
|
if not is_stream:
|
||||||
# 非流式输出,直接请求
|
# Non-streaming: invoke with fallback
|
||||||
|
msg, use_llm_model = await self._invoke_with_fallback(
|
||||||
msg = await use_llm_model.provider.invoke_llm(
|
|
||||||
query,
|
query,
|
||||||
use_llm_model,
|
candidates,
|
||||||
req_messages,
|
req_messages,
|
||||||
query.use_funcs,
|
query.use_funcs,
|
||||||
extra_args=use_llm_model.model_entity.extra_args,
|
remove_think,
|
||||||
remove_think=remove_think,
|
|
||||||
)
|
)
|
||||||
yield msg
|
yield msg
|
||||||
final_msg = msg
|
final_msg = msg
|
||||||
else:
|
else:
|
||||||
# 流式输出,需要处理工具调用
|
# Streaming: invoke with fallback
|
||||||
tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
||||||
msg_idx = 0
|
msg_idx = 0
|
||||||
accumulated_content = '' # 从开始累积的所有内容
|
accumulated_content = ''
|
||||||
last_role = 'assistant'
|
last_role = 'assistant'
|
||||||
msg_sequence = 1
|
msg_sequence = 1
|
||||||
async for msg in use_llm_model.provider.invoke_llm_stream(
|
|
||||||
|
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
|
||||||
query,
|
query,
|
||||||
use_llm_model,
|
candidates,
|
||||||
req_messages,
|
req_messages,
|
||||||
query.use_funcs,
|
query.use_funcs,
|
||||||
extra_args=use_llm_model.model_entity.extra_args,
|
remove_think,
|
||||||
remove_think=remove_think,
|
)
|
||||||
):
|
async for msg in stream_src:
|
||||||
msg_idx = msg_idx + 1
|
msg_idx = msg_idx + 1
|
||||||
|
|
||||||
# 记录角色
|
|
||||||
if msg.role:
|
if msg.role:
|
||||||
last_role = msg.role
|
last_role = msg.role
|
||||||
|
|
||||||
# 累积内容
|
|
||||||
if msg.content:
|
if msg.content:
|
||||||
accumulated_content += msg.content
|
accumulated_content += msg.content
|
||||||
|
|
||||||
# 处理工具调用
|
|
||||||
if msg.tool_calls:
|
if msg.tool_calls:
|
||||||
for tool_call in msg.tool_calls:
|
for tool_call in msg.tool_calls:
|
||||||
if tool_call.id not in tool_calls_map:
|
if tool_call.id not in tool_calls_map:
|
||||||
@@ -169,21 +262,18 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if tool_call.function and tool_call.function.arguments:
|
if tool_call.function and tool_call.function.arguments:
|
||||||
# 流式处理中,工具调用参数可能分多个chunk返回,需要追加而不是覆盖
|
|
||||||
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||||
# continue
|
|
||||||
# 每8个chunk或最后一个chunk时,输出所有累积的内容
|
|
||||||
if msg_idx % 8 == 0 or msg.is_final:
|
if msg_idx % 8 == 0 or msg.is_final:
|
||||||
msg_sequence += 1
|
msg_sequence += 1
|
||||||
yield provider_message.MessageChunk(
|
yield provider_message.MessageChunk(
|
||||||
role=last_role,
|
role=last_role,
|
||||||
content=accumulated_content, # 输出所有累积内容
|
content=accumulated_content,
|
||||||
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
|
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
|
||||||
is_final=msg.is_final,
|
is_final=msg.is_final,
|
||||||
msg_sequence=msg_sequence,
|
msg_sequence=msg_sequence,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 创建最终消息用于后续处理
|
|
||||||
final_msg = provider_message.MessageChunk(
|
final_msg = provider_message.MessageChunk(
|
||||||
role=last_role,
|
role=last_role,
|
||||||
content=accumulated_content,
|
content=accumulated_content,
|
||||||
@@ -198,7 +288,8 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
req_messages.append(final_msg)
|
req_messages.append(final_msg)
|
||||||
|
|
||||||
# 持续请求,只要还有待处理的工具调用就继续处理调用
|
# Once a model succeeds, commit to it for the tool call loop
|
||||||
|
# (no fallback mid-conversation — different models may interpret tool results differently)
|
||||||
while pending_tool_calls:
|
while pending_tool_calls:
|
||||||
for tool_call in pending_tool_calls:
|
for tool_call in pending_tool_calls:
|
||||||
try:
|
try:
|
||||||
@@ -239,7 +330,6 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
req_messages.append(msg)
|
req_messages.append(msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 工具调用出错,添加一个报错信息到 req_messages
|
|
||||||
err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id)
|
err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id)
|
||||||
|
|
||||||
yield err_msg
|
yield err_msg
|
||||||
@@ -247,39 +337,38 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
req_messages.append(err_msg)
|
req_messages.append(err_msg)
|
||||||
|
|
||||||
self.ap.logger.debug(
|
self.ap.logger.debug(
|
||||||
f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}'
|
f'localagent req: query={query.query_id} req_messages={req_messages} '
|
||||||
|
f'use_llm_model={use_llm_model.model_entity.name}'
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_stream:
|
if is_stream:
|
||||||
tool_calls_map = {}
|
tool_calls_map = {}
|
||||||
msg_idx = 0
|
msg_idx = 0
|
||||||
accumulated_content = '' # 从开始累积的所有内容
|
accumulated_content = ''
|
||||||
last_role = 'assistant'
|
last_role = 'assistant'
|
||||||
msg_sequence = first_end_sequence
|
msg_sequence = first_end_sequence
|
||||||
|
|
||||||
async for msg in use_llm_model.provider.invoke_llm_stream(
|
tool_stream_src = use_llm_model.provider.invoke_llm_stream(
|
||||||
query,
|
query,
|
||||||
use_llm_model,
|
use_llm_model,
|
||||||
req_messages,
|
req_messages,
|
||||||
query.use_funcs,
|
query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
|
||||||
extra_args=use_llm_model.model_entity.extra_args,
|
extra_args=use_llm_model.model_entity.extra_args,
|
||||||
remove_think=remove_think,
|
remove_think=remove_think,
|
||||||
):
|
)
|
||||||
|
async for msg in tool_stream_src:
|
||||||
msg_idx += 1
|
msg_idx += 1
|
||||||
|
|
||||||
# 记录角色
|
|
||||||
if msg.role:
|
if msg.role:
|
||||||
last_role = msg.role
|
last_role = msg.role
|
||||||
|
|
||||||
# 第一次请求工具调用时的内容
|
# Prepend first-round content on first chunk of tool-call round
|
||||||
if msg_idx == 1:
|
if msg_idx == 1:
|
||||||
accumulated_content = first_content if first_content is not None else accumulated_content
|
accumulated_content = first_content if first_content is not None else accumulated_content
|
||||||
|
|
||||||
# 累积内容
|
|
||||||
if msg.content:
|
if msg.content:
|
||||||
accumulated_content += msg.content
|
accumulated_content += msg.content
|
||||||
|
|
||||||
# 处理工具调用
|
|
||||||
if msg.tool_calls:
|
if msg.tool_calls:
|
||||||
for tool_call in msg.tool_calls:
|
for tool_call in msg.tool_calls:
|
||||||
if tool_call.id not in tool_calls_map:
|
if tool_call.id not in tool_calls_map:
|
||||||
@@ -291,15 +380,13 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if tool_call.function and tool_call.function.arguments:
|
if tool_call.function and tool_call.function.arguments:
|
||||||
# 流式处理中,工具调用参数可能分多个chunk返回,需要追加而不是覆盖
|
|
||||||
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||||
|
|
||||||
# 每8个chunk或最后一个chunk时,输出所有累积的内容
|
|
||||||
if msg_idx % 8 == 0 or msg.is_final:
|
if msg_idx % 8 == 0 or msg.is_final:
|
||||||
msg_sequence += 1
|
msg_sequence += 1
|
||||||
yield provider_message.MessageChunk(
|
yield provider_message.MessageChunk(
|
||||||
role=last_role,
|
role=last_role,
|
||||||
content=accumulated_content, # 输出所有累积内容
|
content=accumulated_content,
|
||||||
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
|
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
|
||||||
is_final=msg.is_final,
|
is_final=msg.is_final,
|
||||||
msg_sequence=msg_sequence,
|
msg_sequence=msg_sequence,
|
||||||
@@ -312,12 +399,12 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
msg_sequence=msg_sequence,
|
msg_sequence=msg_sequence,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 处理完所有调用,再次请求
|
# Non-streaming: use committed model directly (no fallback in tool loop)
|
||||||
msg = await use_llm_model.provider.invoke_llm(
|
msg = await use_llm_model.provider.invoke_llm(
|
||||||
query,
|
query,
|
||||||
use_llm_model,
|
use_llm_model,
|
||||||
req_messages,
|
req_messages,
|
||||||
query.use_funcs,
|
query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
|
||||||
extra_args=use_llm_model.model_entity.extra_args,
|
extra_args=use_llm_model.model_entity.extra_args,
|
||||||
remove_think=remove_think,
|
remove_think=remove_think,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -321,13 +321,19 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
|||||||
if not plugin_id:
|
if not plugin_id:
|
||||||
raise ValueError(f'No RAG plugin ID configured for KB {kb.uuid}. Retrieval failed.')
|
raise ValueError(f'No RAG plugin ID configured for KB {kb.uuid}. Retrieval failed.')
|
||||||
|
|
||||||
|
# Session context (e.g. session_name) stays in retrieval_settings
|
||||||
|
# for plugins that need it. Do NOT move them into filters, as filters
|
||||||
|
# are passed directly to vector_search by some plugins (e.g. LangRAG)
|
||||||
|
# and would cause empty results when the metadata field doesn't exist.
|
||||||
|
filters = settings.pop('filters', {})
|
||||||
|
|
||||||
retrieval_context = {
|
retrieval_context = {
|
||||||
'query': query,
|
'query': query,
|
||||||
'knowledge_base_id': kb.uuid,
|
'knowledge_base_id': kb.uuid,
|
||||||
'collection_id': kb.collection_id or kb.uuid,
|
'collection_id': kb.collection_id or kb.uuid,
|
||||||
'retrieval_settings': settings,
|
'retrieval_settings': settings,
|
||||||
'creation_settings': kb.creation_settings or {},
|
'creation_settings': kb.creation_settings or {},
|
||||||
'filters': settings.pop('filters', {}),
|
'filters': filters,
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await self.ap.plugin_connector.call_rag_retrieve(
|
result = await self.ap.plugin_connector.call_rag_retrieve(
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ class RAGRuntimeService:
|
|||||||
filters: dict[str, Any] | None = None,
|
filters: dict[str, Any] | None = None,
|
||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
"""Handle VECTOR_SEARCH action."""
|
"""Handle VECTOR_SEARCH action."""
|
||||||
return await self.ap.vector_db_mgr.search(
|
return await self.ap.vector_db_mgr.search(
|
||||||
@@ -50,6 +51,7 @@ class RAGRuntimeService:
|
|||||||
filter=filters,
|
filter=filters,
|
||||||
search_type=search_type,
|
search_type=search_type,
|
||||||
query_text=query_text,
|
query_text=query_text,
|
||||||
|
vector_weight=vector_weight,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def vector_delete(
|
async def vector_delete(
|
||||||
@@ -75,6 +77,31 @@ class RAGRuntimeService:
|
|||||||
count = await self.ap.vector_db_mgr.delete_by_filter(collection_name=collection_id, filter=filters)
|
count = await self.ap.vector_db_mgr.delete_by_filter(collection_name=collection_id, filter=filters)
|
||||||
return count
|
return count
|
||||||
|
|
||||||
|
async def vector_list(
|
||||||
|
self,
|
||||||
|
collection_id: str,
|
||||||
|
filters: dict[str, Any] | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
"""Handle VECTOR_LIST action.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection_id: The collection to list from.
|
||||||
|
filters: Optional metadata filters.
|
||||||
|
limit: Maximum number of items to return.
|
||||||
|
offset: Number of items to skip.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (items, total).
|
||||||
|
"""
|
||||||
|
return await self.ap.vector_db_mgr.list_by_filter(
|
||||||
|
collection_name=collection_id,
|
||||||
|
filter=filters,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
async def get_file_stream(self, storage_path: str) -> bytes:
|
async def get_file_stream(self, storage_path: str) -> bytes:
|
||||||
"""Handle GET_KNOWLEDEGE_FILE_STREAM action.
|
"""Handle GET_KNOWLEDEGE_FILE_STREAM action.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import langbot
|
|||||||
|
|
||||||
semantic_version = f'v{langbot.__version__}'
|
semantic_version = f'v{langbot.__version__}'
|
||||||
|
|
||||||
required_database_version = 19
|
required_database_version = 24
|
||||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||||
|
|
||||||
debug_mode = False
|
debug_mode = False
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ class VersionManager:
|
|||||||
try:
|
try:
|
||||||
if await self.ap.ver_mgr.is_new_version_available():
|
if await self.ap.ver_mgr.is_new_version_available():
|
||||||
return (
|
return (
|
||||||
'New version available:\n有新版本可用,根据文档更新: \nhttps://docs.langbot.app/zh/deploy/update.html',
|
'New version available:\n有新版本可用,根据文档更新: \nhttps://link.langbot.app/zh/docs/update',
|
||||||
logging.INFO,
|
logging.INFO,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -49,17 +49,25 @@ def normalize_filter(
|
|||||||
def strip_unsupported_fields(
|
def strip_unsupported_fields(
|
||||||
triples: list[tuple[str, str, Any]],
|
triples: list[tuple[str, str, Any]],
|
||||||
supported_fields: set[str],
|
supported_fields: set[str],
|
||||||
|
field_aliases: dict[str, str] | None = None,
|
||||||
) -> list[tuple[str, str, Any]]:
|
) -> list[tuple[str, str, Any]]:
|
||||||
"""Return only triples whose field is in *supported_fields*.
|
"""Return only triples whose field is in *supported_fields*.
|
||||||
|
|
||||||
|
If *field_aliases* is provided, aliased field names are mapped to the
|
||||||
|
canonical backend name before the support check. For example,
|
||||||
|
``{'uuid': 'chunk_uuid'}`` allows callers to use ``uuid`` which is
|
||||||
|
transparently rewritten to ``chunk_uuid``.
|
||||||
|
|
||||||
Dropped fields are logged at WARNING level so the caller knows they were
|
Dropped fields are logged at WARNING level so the caller knows they were
|
||||||
silently ignored (useful for Milvus / pgvector which only store a fixed
|
silently ignored (useful for Milvus / pgvector which only store a fixed
|
||||||
schema).
|
schema).
|
||||||
"""
|
"""
|
||||||
|
aliases = field_aliases or {}
|
||||||
kept: list[tuple[str, str, Any]] = []
|
kept: list[tuple[str, str, Any]] = []
|
||||||
for field, op, value in triples:
|
for field, op, value in triples:
|
||||||
if field in supported_fields:
|
resolved = aliases.get(field, field)
|
||||||
kept.append((field, op, value))
|
if resolved in supported_fields:
|
||||||
|
kept.append((resolved, op, value))
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'Filter field %r is not supported by this backend and will be ignored (supported: %s)',
|
'Filter field %r is not supported by this backend and will be ignored (supported: %s)',
|
||||||
|
|||||||
@@ -97,10 +97,11 @@ class VectorDBManager:
|
|||||||
filter: dict | None = None,
|
filter: dict | None = None,
|
||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Proxy: Search vectors.
|
"""Proxy: Search vectors.
|
||||||
|
|
||||||
Returns a list of dicts with keys: 'id', 'score', 'metadata'.
|
Returns a list of dicts with keys: 'id', 'distance', 'metadata'.
|
||||||
The underlying VectorDatabase.search returns Chroma-style format:
|
The underlying VectorDatabase.search returns Chroma-style format:
|
||||||
{ 'ids': [['id1']], 'distances': [[0.1]], 'metadatas': [[{}]] }
|
{ 'ids': [['id1']], 'distances': [[0.1]], 'metadatas': [[{}]] }
|
||||||
"""
|
"""
|
||||||
@@ -111,6 +112,7 @@ class VectorDBManager:
|
|||||||
search_type=search_type,
|
search_type=search_type,
|
||||||
query_text=query_text,
|
query_text=query_text,
|
||||||
filter=filter,
|
filter=filter,
|
||||||
|
vector_weight=vector_weight,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not results or 'ids' not in results or not results['ids']:
|
if not results or 'ids' not in results or not results['ids']:
|
||||||
@@ -130,7 +132,7 @@ class VectorDBManager:
|
|||||||
parsed_results.append(
|
parsed_results.append(
|
||||||
{
|
{
|
||||||
'id': id_val,
|
'id': id_val,
|
||||||
'score': r_dists[i] if r_dists and i < len(r_dists) else 0.0,
|
'distance': r_dists[i] if r_dists and i < len(r_dists) else 0.0,
|
||||||
'metadata': r_metas[i] if r_metas and i < len(r_metas) else {},
|
'metadata': r_metas[i] if r_metas and i < len(r_metas) else {},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -157,3 +159,17 @@ class VectorDBManager:
|
|||||||
Number of deleted vectors (best-effort; some backends return 0).
|
Number of deleted vectors (best-effort; some backends return 0).
|
||||||
"""
|
"""
|
||||||
return await self.vector_db.delete_by_filter(collection_name, filter)
|
return await self.vector_db.delete_by_filter(collection_name, filter)
|
||||||
|
|
||||||
|
async def list_by_filter(
|
||||||
|
self,
|
||||||
|
collection_name: str,
|
||||||
|
filter: dict | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
"""Proxy: List vectors by metadata filter with pagination.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (items, total).
|
||||||
|
"""
|
||||||
|
return await self.vector_db.list_by_filter(collection_name, filter, limit, offset)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class VectorDatabase(abc.ABC):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Search for the most similar vectors in the specified collection.
|
"""Search for the most similar vectors in the specified collection.
|
||||||
|
|
||||||
@@ -70,6 +71,8 @@ class VectorDatabase(abc.ABC):
|
|||||||
{"file_id": "abc"}
|
{"file_id": "abc"}
|
||||||
{"created_at": {"$gte": 1700000000}}
|
{"created_at": {"$gte": 1700000000}}
|
||||||
{"file_type": {"$in": ["pdf", "docx"]}}
|
{"file_type": {"$in": ["pdf", "docx"]}}
|
||||||
|
vector_weight: Weight for vector search in hybrid mode (0.0–1.0).
|
||||||
|
``None`` means use equal weights (backward compatible).
|
||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -92,6 +95,28 @@ class VectorDatabase(abc.ABC):
|
|||||||
"""
|
"""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
async def list_by_filter(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
filter: dict[str, Any] | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
"""List vectors matching the given metadata filter with pagination.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collection: Collection name.
|
||||||
|
filter: Optional metadata filter dict in canonical format.
|
||||||
|
limit: Maximum number of items to return.
|
||||||
|
offset: Number of items to skip.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (items, total) where items is a list of dicts with
|
||||||
|
keys 'id', 'document', 'metadata', and total is the best-effort
|
||||||
|
count of all matching vectors (-1 if unknown).
|
||||||
|
"""
|
||||||
|
return [], -1
|
||||||
|
|
||||||
@abc.abstractmethod
|
@abc.abstractmethod
|
||||||
async def get_or_create_collection(self, collection: str):
|
async def get_or_create_collection(self, collection: str):
|
||||||
"""Get or create collection."""
|
"""Get or create collection."""
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from chromadb import PersistentClient
|
from chromadb import PersistentClient
|
||||||
from langbot.pkg.vector.vdb import VectorDatabase
|
from langbot.pkg.vector.vdb import VectorDatabase, SearchType
|
||||||
from langbot.pkg.core import app
|
from langbot.pkg.core import app
|
||||||
import chromadb
|
import chromadb
|
||||||
import chromadb.errors
|
import chromadb.errors
|
||||||
|
|
||||||
|
# RRF smoothing constant (standard value from the literature)
|
||||||
|
_RRF_K = 60
|
||||||
|
|
||||||
|
|
||||||
class ChromaVectorDatabase(VectorDatabase):
|
class ChromaVectorDatabase(VectorDatabase):
|
||||||
def __init__(self, ap: app.Application, base_path: str = './data/chroma'):
|
def __init__(self, ap: app.Application, base_path: str = './data/chroma'):
|
||||||
@@ -14,6 +17,10 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
self.client = PersistentClient(path=base_path)
|
self.client = PersistentClient(path=base_path)
|
||||||
self._collections = {}
|
self._collections = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def supported_search_types(cls) -> list[SearchType]:
|
||||||
|
return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID]
|
||||||
|
|
||||||
async def get_or_create_collection(self, collection: str) -> chromadb.Collection:
|
async def get_or_create_collection(self, collection: str) -> chromadb.Collection:
|
||||||
if collection not in self._collections:
|
if collection not in self._collections:
|
||||||
self._collections[collection] = await asyncio.to_thread(
|
self._collections[collection] = await asyncio.to_thread(
|
||||||
@@ -34,8 +41,8 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
kwargs: dict[str, Any] = dict(embeddings=embeddings_list, ids=ids, metadatas=metadatas)
|
kwargs: dict[str, Any] = dict(embeddings=embeddings_list, ids=ids, metadatas=metadatas)
|
||||||
if documents is not None:
|
if documents is not None:
|
||||||
kwargs['documents'] = documents
|
kwargs['documents'] = documents
|
||||||
await asyncio.to_thread(col.add, **kwargs)
|
await asyncio.to_thread(col.upsert, **kwargs)
|
||||||
self.ap.logger.info(f"Added {len(ids)} embeddings to Chroma collection '{collection}'.")
|
self.ap.logger.info(f"Upserted {len(ids)} embeddings to Chroma collection '{collection}'.")
|
||||||
|
|
||||||
async def search(
|
async def search(
|
||||||
self,
|
self,
|
||||||
@@ -45,8 +52,28 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
col = await self.get_or_create_collection(collection)
|
col = await self.get_or_create_collection(collection)
|
||||||
|
|
||||||
|
if search_type == SearchType.FULL_TEXT:
|
||||||
|
return await self._full_text_search(col, collection, k, query_text, filter)
|
||||||
|
elif search_type == SearchType.HYBRID:
|
||||||
|
return await self._hybrid_search(
|
||||||
|
col, collection, query_embedding, k, query_text, filter, vector_weight=vector_weight
|
||||||
|
)
|
||||||
|
|
||||||
|
# Default: vector search
|
||||||
|
return await self._vector_search(col, collection, query_embedding, k, filter)
|
||||||
|
|
||||||
|
async def _vector_search(
|
||||||
|
self,
|
||||||
|
col: chromadb.Collection,
|
||||||
|
collection: str,
|
||||||
|
query_embedding: list[float],
|
||||||
|
k: int,
|
||||||
|
filter: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
query_kwargs: dict[str, Any] = dict(
|
query_kwargs: dict[str, Any] = dict(
|
||||||
query_embeddings=query_embedding,
|
query_embeddings=query_embedding,
|
||||||
n_results=k,
|
n_results=k,
|
||||||
@@ -55,9 +82,154 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
if filter:
|
if filter:
|
||||||
query_kwargs['where'] = filter
|
query_kwargs['where'] = filter
|
||||||
results = await asyncio.to_thread(col.query, **query_kwargs)
|
results = await asyncio.to_thread(col.query, **query_kwargs)
|
||||||
self.ap.logger.info(f"Chroma search in '{collection}' returned {len(results.get('ids', [[]])[0])} results.")
|
self.ap.logger.info(
|
||||||
|
f"Chroma vector search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
async def _full_text_search(
|
||||||
|
self,
|
||||||
|
col: chromadb.Collection,
|
||||||
|
collection: str,
|
||||||
|
k: int,
|
||||||
|
query_text: str,
|
||||||
|
filter: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not query_text:
|
||||||
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||||
|
|
||||||
|
get_kwargs: dict[str, Any] = dict(
|
||||||
|
where_document={'$contains': query_text},
|
||||||
|
include=['metadatas', 'documents'],
|
||||||
|
limit=k,
|
||||||
|
)
|
||||||
|
if filter:
|
||||||
|
get_kwargs['where'] = filter
|
||||||
|
results = await asyncio.to_thread(col.get, **get_kwargs)
|
||||||
|
|
||||||
|
# col.get returns flat lists; wrap into column-major format.
|
||||||
|
# Distances are all 0.0 because Chroma's local $contains is a boolean
|
||||||
|
# filter with no relevance scoring. Chroma's BM25 sparse embedding
|
||||||
|
# function (ChromaBm25EmbeddingFunction) can generate scored sparse
|
||||||
|
# vectors, but sparse vector *indexing* is only available on Chroma
|
||||||
|
# Cloud, not locally. For ranked results, use hybrid mode or apply a
|
||||||
|
# reranker in a downstream stage.
|
||||||
|
ids = results.get('ids', [])
|
||||||
|
metadatas = results.get('metadatas', []) or [None] * len(ids)
|
||||||
|
documents = results.get('documents', []) or [None] * len(ids)
|
||||||
|
distances = [0.0] * len(ids)
|
||||||
|
|
||||||
|
self.ap.logger.info(f"Chroma full-text search in '{collection}' returned {len(ids)} results.")
|
||||||
|
return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances], 'documents': [documents]}
|
||||||
|
|
||||||
|
async def _hybrid_search(
|
||||||
|
self,
|
||||||
|
col: chromadb.Collection,
|
||||||
|
collection: str,
|
||||||
|
query_embedding: list[float],
|
||||||
|
k: int,
|
||||||
|
query_text: str,
|
||||||
|
filter: dict[str, Any] | None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
# Fall back to pure vector search when no text is provided
|
||||||
|
if not query_text:
|
||||||
|
return await self._vector_search(col, collection, query_embedding, k, filter)
|
||||||
|
|
||||||
|
# Run vector search and full-text search in parallel
|
||||||
|
vector_task = self._vector_search(col, collection, query_embedding, k, filter)
|
||||||
|
text_task = self._full_text_search(col, collection, k, query_text, filter)
|
||||||
|
vector_results, text_results = await asyncio.gather(vector_task, text_task)
|
||||||
|
|
||||||
|
vector_ids = vector_results.get('ids', [[]])[0]
|
||||||
|
text_ids = text_results.get('ids', [[]])[0]
|
||||||
|
|
||||||
|
if not vector_ids and not text_ids:
|
||||||
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||||
|
|
||||||
|
# RRF fusion
|
||||||
|
weights = None
|
||||||
|
if vector_weight is not None:
|
||||||
|
weights = [vector_weight, 1.0 - vector_weight]
|
||||||
|
self.ap.logger.info(
|
||||||
|
f"Chroma hybrid fusion config in '{collection}': "
|
||||||
|
f'vector_weight={vector_weight}, weights={weights or [1.0, 1.0]}, '
|
||||||
|
f'vector_hits={len(vector_ids)}, text_hits={len(text_ids)}'
|
||||||
|
)
|
||||||
|
fused = self._rrf_fuse([vector_ids, text_ids], k, weights=weights)
|
||||||
|
if not fused:
|
||||||
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||||
|
|
||||||
|
fused_ids = [doc_id for doc_id, _ in fused]
|
||||||
|
|
||||||
|
# Fetch full metadata and documents for fused results
|
||||||
|
fetched = await asyncio.to_thread(col.get, ids=fused_ids, include=['metadatas', 'documents'])
|
||||||
|
|
||||||
|
# col.get returns results in arbitrary order; re-order to match fused ranking
|
||||||
|
fetched_map: dict[str, tuple] = {}
|
||||||
|
for i, fid in enumerate(fetched.get('ids', [])):
|
||||||
|
meta = (fetched.get('metadatas') or [None] * len(fetched['ids']))[i]
|
||||||
|
doc = (fetched.get('documents') or [None] * len(fetched['ids']))[i]
|
||||||
|
fetched_map[fid] = (meta, doc)
|
||||||
|
|
||||||
|
ordered_ids = []
|
||||||
|
ordered_metas = []
|
||||||
|
ordered_docs = []
|
||||||
|
ordered_dists = []
|
||||||
|
|
||||||
|
# Normalize RRF scores to 0~1 distances via min-max scaling.
|
||||||
|
# Raw RRF scores are tiny (e.g. 0.016~0.033 with k=60) so a naive
|
||||||
|
# ``1 - score`` would compress all distances into a narrow 0.96~0.98
|
||||||
|
# band with almost no discriminative power. Min-max normalization
|
||||||
|
# spreads them across the full 0~1 range (0.0 = best match).
|
||||||
|
max_score = fused[0][1]
|
||||||
|
min_score = fused[-1][1]
|
||||||
|
score_range = max_score - min_score
|
||||||
|
|
||||||
|
for doc_id, score in fused:
|
||||||
|
if doc_id in fetched_map:
|
||||||
|
meta, doc = fetched_map[doc_id]
|
||||||
|
ordered_ids.append(doc_id)
|
||||||
|
ordered_metas.append(meta)
|
||||||
|
ordered_docs.append(doc)
|
||||||
|
if score_range > 0:
|
||||||
|
ordered_dists.append(1.0 - (score - min_score) / score_range)
|
||||||
|
else:
|
||||||
|
ordered_dists.append(0.0)
|
||||||
|
|
||||||
|
self.ap.logger.info(
|
||||||
|
f"Chroma hybrid search in '{collection}' returned {len(ordered_ids)} results "
|
||||||
|
f'(vector={len(vector_ids)}, text={len(text_ids)}).'
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'ids': [ordered_ids],
|
||||||
|
'metadatas': [ordered_metas],
|
||||||
|
'distances': [ordered_dists],
|
||||||
|
'documents': [ordered_docs],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rrf_fuse(result_lists: list[list[str]], k: int, weights: list[float] | None = None) -> list[tuple[str, float]]:
|
||||||
|
"""Reciprocal Rank Fusion over multiple ranked ID lists.
|
||||||
|
|
||||||
|
Returns a list of (doc_id, rrf_score) sorted by descending score,
|
||||||
|
truncated to *k* entries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
result_lists: Ranked ID lists from different search methods.
|
||||||
|
k: Number of results to return.
|
||||||
|
weights: Per-list weights. ``None`` means equal weight (1.0 each).
|
||||||
|
"""
|
||||||
|
if weights is None:
|
||||||
|
weights = [1.0] * len(result_lists)
|
||||||
|
scores: dict[str, float] = {}
|
||||||
|
for list_idx, ranked_ids in enumerate(result_lists):
|
||||||
|
w = weights[list_idx]
|
||||||
|
for rank, doc_id in enumerate(ranked_ids):
|
||||||
|
scores[doc_id] = scores.get(doc_id, 0.0) + w / (_RRF_K + rank + 1)
|
||||||
|
sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
return sorted_results[:k]
|
||||||
|
|
||||||
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
|
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
|
||||||
col = await self.get_or_create_collection(collection)
|
col = await self.get_or_create_collection(collection)
|
||||||
await asyncio.to_thread(col.delete, where={'file_id': file_id})
|
await asyncio.to_thread(col.delete, where={'file_id': file_id})
|
||||||
@@ -69,6 +241,41 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
self.ap.logger.info(f"Deleted embeddings from Chroma collection '{collection}' by filter")
|
self.ap.logger.info(f"Deleted embeddings from Chroma collection '{collection}' by filter")
|
||||||
return 0 # Chroma delete does not return a count
|
return 0 # Chroma delete does not return a count
|
||||||
|
|
||||||
|
async def list_by_filter(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
filter: dict[str, Any] | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
col = await self.get_or_create_collection(collection)
|
||||||
|
get_kwargs: dict[str, Any] = dict(
|
||||||
|
include=['metadatas', 'documents'],
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
if filter:
|
||||||
|
get_kwargs['where'] = filter
|
||||||
|
results = await asyncio.to_thread(col.get, **get_kwargs)
|
||||||
|
|
||||||
|
ids = results.get('ids', [])
|
||||||
|
metadatas = results.get('metadatas', []) or [None] * len(ids)
|
||||||
|
documents = results.get('documents', []) or [None] * len(ids)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for i, vid in enumerate(ids):
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
'id': vid,
|
||||||
|
'document': documents[i] if i < len(documents) else None,
|
||||||
|
'metadata': metadatas[i] if i < len(metadatas) else {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Chroma col.count() gives total in collection; filtered count not available
|
||||||
|
total = await asyncio.to_thread(col.count) if not filter else -1
|
||||||
|
return items, total
|
||||||
|
|
||||||
async def delete_collection(self, collection: str):
|
async def delete_collection(self, collection: str):
|
||||||
if collection in self._collections:
|
if collection in self._collections:
|
||||||
del self._collections[collection]
|
del self._collections[collection]
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ from langbot.pkg.core import app
|
|||||||
# silently dropped with a warning.
|
# silently dropped with a warning.
|
||||||
_MILVUS_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
|
_MILVUS_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
|
||||||
|
|
||||||
|
# Callers use canonical metadata key 'uuid' but Milvus stores it as 'chunk_uuid'.
|
||||||
|
_MILVUS_FIELD_ALIASES = {'uuid': 'chunk_uuid'}
|
||||||
|
|
||||||
|
|
||||||
def _build_milvus_expr(filter_dict: dict[str, Any]) -> str:
|
def _build_milvus_expr(filter_dict: dict[str, Any]) -> str:
|
||||||
"""Translate canonical filter dict into a Milvus boolean expression string."""
|
"""Translate canonical filter dict into a Milvus boolean expression string."""
|
||||||
triples = normalize_filter(filter_dict)
|
triples = normalize_filter(filter_dict)
|
||||||
triples = strip_unsupported_fields(triples, _MILVUS_SUPPORTED_FIELDS)
|
triples = strip_unsupported_fields(triples, _MILVUS_SUPPORTED_FIELDS, _MILVUS_FIELD_ALIASES)
|
||||||
if not triples:
|
if not triples:
|
||||||
return ''
|
return ''
|
||||||
|
|
||||||
@@ -252,6 +255,7 @@ class MilvusVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Search for similar vectors in Milvus collection
|
"""Search for similar vectors in Milvus collection
|
||||||
|
|
||||||
@@ -340,6 +344,62 @@ class MilvusVectorDatabase(VectorDatabase):
|
|||||||
self.ap.logger.info(f"Deleted embeddings from Milvus collection '{collection}' by filter")
|
self.ap.logger.info(f"Deleted embeddings from Milvus collection '{collection}' by filter")
|
||||||
return 0 # Milvus delete does not return a count
|
return 0 # Milvus delete does not return a count
|
||||||
|
|
||||||
|
async def list_by_filter(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
filter: dict[str, Any] | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
|
await self.get_or_create_collection(collection)
|
||||||
|
|
||||||
|
query_kwargs: dict[str, Any] = dict(
|
||||||
|
collection_name=collection,
|
||||||
|
output_fields=['text', 'file_id', 'chunk_uuid'],
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
if filter:
|
||||||
|
expr = _build_milvus_expr(filter)
|
||||||
|
if expr:
|
||||||
|
query_kwargs['filter'] = expr
|
||||||
|
|
||||||
|
results = await asyncio.to_thread(self.client.query, **query_kwargs)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in results:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
'id': row.get('id', ''),
|
||||||
|
'document': row.get('text'),
|
||||||
|
'metadata': {
|
||||||
|
'text': row.get('text', ''),
|
||||||
|
'file_id': row.get('file_id', ''),
|
||||||
|
'uuid': row.get('chunk_uuid', ''),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Milvus query with count(*)
|
||||||
|
total = -1
|
||||||
|
try:
|
||||||
|
count_kwargs: dict[str, Any] = dict(
|
||||||
|
collection_name=collection,
|
||||||
|
output_fields=['count(*)'],
|
||||||
|
)
|
||||||
|
if filter:
|
||||||
|
expr = _build_milvus_expr(filter)
|
||||||
|
if expr:
|
||||||
|
count_kwargs['filter'] = expr
|
||||||
|
count_result = await asyncio.to_thread(self.client.query, **count_kwargs)
|
||||||
|
if count_result:
|
||||||
|
total = count_result[0].get('count(*)', -1)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return items, total
|
||||||
|
|
||||||
async def delete_collection(self, collection: str):
|
async def delete_collection(self, collection: str):
|
||||||
"""Delete a Milvus collection
|
"""Delete a Milvus collection
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ Base = declarative_base()
|
|||||||
# pgvector schema only stores these metadata fields.
|
# pgvector schema only stores these metadata fields.
|
||||||
_PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
|
_PG_SUPPORTED_FIELDS = {'text', 'file_id', 'chunk_uuid'}
|
||||||
|
|
||||||
|
# Callers use canonical metadata key 'uuid' but pgvector stores it as 'chunk_uuid'.
|
||||||
|
_PG_FIELD_ALIASES = {'uuid': 'chunk_uuid'}
|
||||||
|
|
||||||
# Map schema field names to SQLAlchemy columns (resolved lazily from PgVectorEntry).
|
# Map schema field names to SQLAlchemy columns (resolved lazily from PgVectorEntry).
|
||||||
_PG_COLUMN_MAP = {
|
_PG_COLUMN_MAP = {
|
||||||
'text': 'text',
|
'text': 'text',
|
||||||
@@ -37,7 +40,7 @@ class PgVectorEntry(Base):
|
|||||||
def _build_pg_conditions(filter_dict: dict[str, Any]) -> list:
|
def _build_pg_conditions(filter_dict: dict[str, Any]) -> list:
|
||||||
"""Translate canonical filter dict into a list of SQLAlchemy conditions."""
|
"""Translate canonical filter dict into a list of SQLAlchemy conditions."""
|
||||||
triples = normalize_filter(filter_dict)
|
triples = normalize_filter(filter_dict)
|
||||||
triples = strip_unsupported_fields(triples, _PG_SUPPORTED_FIELDS)
|
triples = strip_unsupported_fields(triples, _PG_SUPPORTED_FIELDS, _PG_FIELD_ALIASES)
|
||||||
|
|
||||||
conditions = []
|
conditions = []
|
||||||
for field, op, value in triples:
|
for field, op, value in triples:
|
||||||
@@ -189,6 +192,7 @@ class PgVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Search for similar vectors using cosine distance
|
"""Search for similar vectors using cosine distance
|
||||||
|
|
||||||
@@ -309,6 +313,65 @@ class PgVectorDatabase(VectorDatabase):
|
|||||||
self.ap.logger.error(f'Error deleting from pgvector by filter: {e}')
|
self.ap.logger.error(f'Error deleting from pgvector by filter: {e}')
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
async def list_by_filter(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
filter: dict[str, Any] | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
await self.get_or_create_collection(collection)
|
||||||
|
|
||||||
|
async with self.AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
PgVectorEntry.id,
|
||||||
|
PgVectorEntry.text,
|
||||||
|
PgVectorEntry.file_id,
|
||||||
|
PgVectorEntry.chunk_uuid,
|
||||||
|
)
|
||||||
|
.filter(PgVectorEntry.collection == collection)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
|
||||||
|
count_stmt = (
|
||||||
|
select(func.count()).select_from(PgVectorEntry).filter(PgVectorEntry.collection == collection)
|
||||||
|
)
|
||||||
|
|
||||||
|
if filter:
|
||||||
|
for cond in _build_pg_conditions(filter):
|
||||||
|
stmt = stmt.filter(cond)
|
||||||
|
count_stmt = count_stmt.filter(cond)
|
||||||
|
|
||||||
|
result = await session.execute(stmt)
|
||||||
|
rows = result.fetchall()
|
||||||
|
|
||||||
|
count_result = await session.execute(count_stmt)
|
||||||
|
total = count_result.scalar() or 0
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in rows:
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
'id': row.id,
|
||||||
|
'document': row.text or '',
|
||||||
|
'metadata': {
|
||||||
|
'text': row.text or '',
|
||||||
|
'file_id': row.file_id or '',
|
||||||
|
'uuid': row.chunk_uuid or '',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return items, total
|
||||||
|
except Exception as e:
|
||||||
|
self.ap.logger.error(f'Error listing from pgvector: {e}')
|
||||||
|
raise
|
||||||
|
|
||||||
async def delete_collection(self, collection: str):
|
async def delete_collection(self, collection: str):
|
||||||
"""Delete all vectors in a collection
|
"""Delete all vectors in a collection
|
||||||
|
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ class QdrantVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
exists = await self.client.collection_exists(collection)
|
exists = await self.client.collection_exists(collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
@@ -150,6 +151,97 @@ class QdrantVectorDatabase(VectorDatabase):
|
|||||||
self.ap.logger.info(f"Deleted embeddings from Qdrant collection '{collection}' by filter")
|
self.ap.logger.info(f"Deleted embeddings from Qdrant collection '{collection}' by filter")
|
||||||
return 0 # Qdrant delete does not return a count
|
return 0 # Qdrant delete does not return a count
|
||||||
|
|
||||||
|
async def list_by_filter(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
filter: dict[str, Any] | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[dict[str, Any]], int]:
|
||||||
|
exists = await self.client.collection_exists(collection)
|
||||||
|
if not exists:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
qdrant_filter = _build_qdrant_filter(filter) if filter else None
|
||||||
|
|
||||||
|
# Qdrant scroll uses cursor-based pagination (offset = point ID),
|
||||||
|
# not numeric skip. To support numeric offset we scroll through
|
||||||
|
# `offset + limit` items and discard the first `offset`.
|
||||||
|
remaining_to_skip = offset
|
||||||
|
remaining_to_collect = limit
|
||||||
|
cursor: int | str | None = None
|
||||||
|
collected: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
while remaining_to_skip > 0 or remaining_to_collect > 0:
|
||||||
|
batch_size = remaining_to_skip + remaining_to_collect if remaining_to_skip > 0 else remaining_to_collect
|
||||||
|
scroll_kwargs: dict[str, Any] = dict(
|
||||||
|
collection_name=collection,
|
||||||
|
limit=min(batch_size, 256),
|
||||||
|
with_payload=True if remaining_to_skip == 0 else False,
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
if qdrant_filter:
|
||||||
|
scroll_kwargs['scroll_filter'] = qdrant_filter
|
||||||
|
if cursor is not None:
|
||||||
|
scroll_kwargs['offset'] = cursor
|
||||||
|
|
||||||
|
points, next_cursor = await self.client.scroll(**scroll_kwargs)
|
||||||
|
if not points:
|
||||||
|
break
|
||||||
|
|
||||||
|
for point in points:
|
||||||
|
if remaining_to_skip > 0:
|
||||||
|
remaining_to_skip -= 1
|
||||||
|
continue
|
||||||
|
if remaining_to_collect <= 0:
|
||||||
|
break
|
||||||
|
# Re-fetch payload if we skipped it during the skip phase
|
||||||
|
payload = point.payload or {}
|
||||||
|
collected.append(
|
||||||
|
{
|
||||||
|
'id': str(point.id),
|
||||||
|
'document': payload.get('text') or payload.get('document'),
|
||||||
|
'metadata': payload,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
remaining_to_collect -= 1
|
||||||
|
|
||||||
|
if next_cursor is None:
|
||||||
|
break
|
||||||
|
cursor = next_cursor
|
||||||
|
|
||||||
|
# If we skipped without payload, re-fetch the collected items' payloads
|
||||||
|
# (only needed when offset > 0 and items were collected in a skip batch)
|
||||||
|
if offset > 0 and collected:
|
||||||
|
refetch_ids = [item['id'] for item in collected if not item.get('metadata')]
|
||||||
|
if refetch_ids:
|
||||||
|
fetched_points = await self.client.retrieve(
|
||||||
|
collection_name=collection,
|
||||||
|
ids=refetch_ids,
|
||||||
|
with_payload=True,
|
||||||
|
with_vectors=False,
|
||||||
|
)
|
||||||
|
payload_map = {str(p.id): p.payload or {} for p in fetched_points}
|
||||||
|
for item in collected:
|
||||||
|
if item['id'] in payload_map:
|
||||||
|
payload = payload_map[item['id']]
|
||||||
|
item['metadata'] = payload
|
||||||
|
item['document'] = payload.get('text') or payload.get('document')
|
||||||
|
|
||||||
|
# Use count() for accurate total (supports filter)
|
||||||
|
total = -1
|
||||||
|
try:
|
||||||
|
count_result = await self.client.count(
|
||||||
|
collection_name=collection,
|
||||||
|
count_filter=qdrant_filter,
|
||||||
|
exact=True,
|
||||||
|
)
|
||||||
|
total = count_result.count
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return collected, total
|
||||||
|
|
||||||
async def delete_collection(self, collection: str):
|
async def delete_collection(self, collection: str):
|
||||||
try:
|
try:
|
||||||
await self.client.delete_collection(collection)
|
await self.client.delete_collection(collection)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from decimal import Decimal
|
||||||
|
import re
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
|
||||||
@@ -101,8 +103,28 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _normalize_collection_name(self, collection: str) -> str:
|
||||||
|
"""SeekDB only accepts [a-zA-Z0-9_], while LangBot uses UUID-like KB IDs."""
|
||||||
|
normalized = re.sub(r'[^A-Za-z0-9_]', '_', collection)
|
||||||
|
if normalized != collection:
|
||||||
|
self.ap.logger.info(f"Normalized SeekDB collection name: '{collection}' -> '{normalized}'")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _json_safe(self, value: Any) -> Any:
|
||||||
|
"""Convert SeekDB result values into JSON-serializable Python primitives."""
|
||||||
|
if isinstance(value, Decimal):
|
||||||
|
return float(value)
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {k: self._json_safe(v) for k, v in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [self._json_safe(v) for v in value]
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return [self._json_safe(v) for v in value]
|
||||||
|
return value
|
||||||
|
|
||||||
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None) -> Any:
|
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."""
|
"""Internal method to get or create a collection with proper configuration."""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
if collection in self._collections:
|
if collection in self._collections:
|
||||||
return self._collections[collection]
|
return self._collections[collection]
|
||||||
|
|
||||||
@@ -173,6 +195,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
if not embeddings_list:
|
if not embeddings_list:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
# Ensure collection exists with correct dimension
|
# Ensure collection exists with correct dimension
|
||||||
vector_size = len(embeddings_list[0])
|
vector_size = len(embeddings_list[0])
|
||||||
coll = await self._get_or_create_collection_internal(collection, vector_size)
|
coll = await self._get_or_create_collection_internal(collection, vector_size)
|
||||||
@@ -194,6 +217,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
search_type: str = 'vector',
|
search_type: str = 'vector',
|
||||||
query_text: str = '',
|
query_text: str = '',
|
||||||
filter: Dict[str, Any] | None = None,
|
filter: Dict[str, Any] | None = None,
|
||||||
|
vector_weight: float | None = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Search for the most similar vectors in the specified collection.
|
"""Search for the most similar vectors in the specified collection.
|
||||||
|
|
||||||
@@ -210,6 +234,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with 'ids', 'metadatas', 'distances' keys
|
Dictionary with 'ids', 'metadatas', 'distances' keys
|
||||||
"""
|
"""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
# Check if collection exists
|
# Check if collection exists
|
||||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
@@ -271,6 +296,17 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
query_cfg['where'] = filter
|
query_cfg['where'] = filter
|
||||||
knn_cfg['where'] = filter
|
knn_cfg['where'] = filter
|
||||||
|
|
||||||
|
# Apply vector_weight via pyseekdb's native boost parameter
|
||||||
|
if vector_weight is not None:
|
||||||
|
knn_cfg['boost'] = vector_weight
|
||||||
|
query_cfg['boost'] = 1.0 - vector_weight
|
||||||
|
self.ap.logger.info(
|
||||||
|
f"SeekDB hybrid fusion config in '{collection}': "
|
||||||
|
f'vector_weight={vector_weight}, '
|
||||||
|
f'knn_boost={knn_cfg.get("boost", 1.0)}, '
|
||||||
|
f'query_boost={query_cfg.get("boost", 1.0)}'
|
||||||
|
)
|
||||||
|
|
||||||
results = await asyncio.to_thread(
|
results = await asyncio.to_thread(
|
||||||
coll.hybrid_search,
|
coll.hybrid_search,
|
||||||
query=query_cfg,
|
query=query_cfg,
|
||||||
@@ -279,6 +315,9 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
n_results=k,
|
n_results=k,
|
||||||
include=['documents', 'metadatas'],
|
include=['documents', 'metadatas'],
|
||||||
)
|
)
|
||||||
|
self.ap.logger.info(
|
||||||
|
f"SeekDB hybrid search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
# Default: vector search via query()
|
# Default: vector search via query()
|
||||||
query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}
|
query_kwargs = {'n_results': k, 'query_embeddings': query_embedding}
|
||||||
@@ -286,6 +325,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
query_kwargs['where'] = filter
|
query_kwargs['where'] = filter
|
||||||
results = await asyncio.to_thread(coll.query, **query_kwargs)
|
results = await asyncio.to_thread(coll.query, **query_kwargs)
|
||||||
|
|
||||||
|
results = self._json_safe(results)
|
||||||
self.ap.logger.info(
|
self.ap.logger.info(
|
||||||
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
|
f"SeekDB {search_type} search in '{collection}' returned {len(results.get('ids', [[]])[0])} results"
|
||||||
)
|
)
|
||||||
@@ -299,6 +339,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
collection: Collection name
|
collection: Collection name
|
||||||
file_id: File ID to delete
|
file_id: File ID to delete
|
||||||
"""
|
"""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
# Check if collection exists
|
# Check if collection exists
|
||||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
@@ -325,6 +366,7 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
collection: Collection name
|
collection: Collection name
|
||||||
filter: Chroma-style ``where`` filter dict
|
filter: Chroma-style ``where`` filter dict
|
||||||
"""
|
"""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||||
if not exists:
|
if not exists:
|
||||||
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
|
self.ap.logger.warning(f"SeekDB collection '{collection}' not found for deletion")
|
||||||
@@ -340,12 +382,59 @@ class SeekDBVectorDatabase(VectorDatabase):
|
|||||||
self.ap.logger.info(f"Deleted embeddings from SeekDB collection '{collection}' by filter")
|
self.ap.logger.info(f"Deleted embeddings from SeekDB collection '{collection}' by filter")
|
||||||
return 0 # SeekDB delete does not return a count
|
return 0 # SeekDB delete does not return a count
|
||||||
|
|
||||||
|
async def list_by_filter(
|
||||||
|
self,
|
||||||
|
collection: str,
|
||||||
|
filter: Dict[str, Any] | None = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> tuple[list[Dict[str, Any]], int]:
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
|
exists = await asyncio.to_thread(self.client.has_collection, collection)
|
||||||
|
if not exists:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
get_kwargs: Dict[str, Any] = dict(
|
||||||
|
include=['metadatas', 'documents'],
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
if filter:
|
||||||
|
get_kwargs['where'] = filter
|
||||||
|
|
||||||
|
results = await asyncio.to_thread(coll.get, **get_kwargs)
|
||||||
|
|
||||||
|
results = self._json_safe(results)
|
||||||
|
ids = results.get('ids', [])
|
||||||
|
metadatas = results.get('metadatas', []) or [None] * len(ids)
|
||||||
|
documents = results.get('documents', []) or [None] * len(ids)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for i, vid in enumerate(ids):
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
'id': vid,
|
||||||
|
'document': documents[i] if i < len(documents) else None,
|
||||||
|
'metadata': metadatas[i] if i < len(metadatas) else {},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
total = await asyncio.to_thread(coll.count) if not filter else -1
|
||||||
|
return items, total
|
||||||
|
|
||||||
async def delete_collection(self, collection: str):
|
async def delete_collection(self, collection: str):
|
||||||
"""Delete the entire collection.
|
"""Delete the entire collection.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
collection: Collection name
|
collection: Collection name
|
||||||
"""
|
"""
|
||||||
|
collection = self._normalize_collection_name(collection)
|
||||||
# Remove from cache
|
# Remove from cache
|
||||||
if collection in self._collections:
|
if collection in self._collections:
|
||||||
del self._collections[collection]
|
del self._collections[collection]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ admins: []
|
|||||||
api:
|
api:
|
||||||
port: 5300
|
port: 5300
|
||||||
webhook_prefix: 'http://127.0.0.1:5300'
|
webhook_prefix: 'http://127.0.0.1:5300'
|
||||||
|
extra_webhook_prefix: ''
|
||||||
command:
|
command:
|
||||||
enable: true
|
enable: true
|
||||||
prefix:
|
prefix:
|
||||||
@@ -15,6 +16,7 @@ proxy:
|
|||||||
http: ''
|
http: ''
|
||||||
https: ''
|
https: ''
|
||||||
system:
|
system:
|
||||||
|
instance_id: ''
|
||||||
edition: community
|
edition: community
|
||||||
recovery_key: ''
|
recovery_key: ''
|
||||||
allow_modify_login_info: true
|
allow_modify_login_info: true
|
||||||
@@ -76,6 +78,14 @@ plugin:
|
|||||||
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||||
enable_marketplace: true
|
enable_marketplace: true
|
||||||
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
||||||
|
monitoring:
|
||||||
|
auto_cleanup:
|
||||||
|
# Enable automatic cleanup of expired monitoring records
|
||||||
|
enabled: true
|
||||||
|
# Retention period in days, records older than this will be deleted
|
||||||
|
retention_days: 30
|
||||||
|
# Cleanup check interval in hours
|
||||||
|
check_interval_hours: 1
|
||||||
space:
|
space:
|
||||||
# Space service URL for OAuth and API
|
# Space service URL for OAuth and API
|
||||||
url: 'https://space.langbot.app'
|
url: 'https://space.langbot.app'
|
||||||
|
|||||||
@@ -41,7 +41,10 @@
|
|||||||
"runner": "local-agent"
|
"runner": "local-agent"
|
||||||
},
|
},
|
||||||
"local-agent": {
|
"local-agent": {
|
||||||
"model": "",
|
"model": {
|
||||||
|
"primary": "",
|
||||||
|
"fallbacks": []
|
||||||
|
},
|
||||||
"max-round": 10,
|
"max-round": 10,
|
||||||
"prompt": [
|
"prompt": [
|
||||||
{
|
{
|
||||||
@@ -95,11 +98,12 @@
|
|||||||
"max": 0
|
"max": 0
|
||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"hide-exception": true,
|
"exception-handling": "show-hint",
|
||||||
|
"failure-hint": "Request failed.",
|
||||||
"at-sender": true,
|
"at-sender": true,
|
||||||
"quote-origin": true,
|
"quote-origin": true,
|
||||||
"track-function-calls": false,
|
"track-function-calls": false,
|
||||||
"remove-think": false
|
"remove-think": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,30 +23,30 @@ stages:
|
|||||||
label:
|
label:
|
||||||
en_US: Local Agent
|
en_US: Local Agent
|
||||||
zh_Hans: 内置 Agent
|
zh_Hans: 内置 Agent
|
||||||
- name: tbox-app-api
|
|
||||||
label:
|
|
||||||
en_US: Tbox App API
|
|
||||||
zh_Hans: 蚂蚁百宝箱平台 API
|
|
||||||
- name: dify-service-api
|
- name: dify-service-api
|
||||||
label:
|
label:
|
||||||
en_US: Dify Service API
|
en_US: Dify Service API
|
||||||
zh_Hans: Dify 服务 API
|
zh_Hans: Dify 服务 API
|
||||||
- name: dashscope-app-api
|
|
||||||
label:
|
|
||||||
en_US: Aliyun Dashscope App API
|
|
||||||
zh_Hans: 阿里云百炼平台 API
|
|
||||||
- name: n8n-service-api
|
- name: n8n-service-api
|
||||||
label:
|
label:
|
||||||
en_US: n8n Workflow API
|
en_US: n8n Workflow API
|
||||||
zh_Hans: n8n 工作流 API
|
zh_Hans: n8n 工作流 API
|
||||||
- name: langflow-api
|
|
||||||
label:
|
|
||||||
en_US: Langflow API
|
|
||||||
zh_Hans: Langflow API
|
|
||||||
- name: coze-api
|
- name: coze-api
|
||||||
label:
|
label:
|
||||||
en_US: Coze API
|
en_US: Coze API
|
||||||
zh_Hans: 扣子 API
|
zh_Hans: 扣子 API
|
||||||
|
- name: tbox-app-api
|
||||||
|
label:
|
||||||
|
en_US: Tbox App API
|
||||||
|
zh_Hans: 蚂蚁百宝箱平台 API
|
||||||
|
- name: dashscope-app-api
|
||||||
|
label:
|
||||||
|
en_US: Aliyun Dashscope App API
|
||||||
|
zh_Hans: 阿里云百炼平台 API
|
||||||
|
- name: langflow-api
|
||||||
|
label:
|
||||||
|
en_US: Langflow API
|
||||||
|
zh_Hans: Langflow API
|
||||||
- name: local-agent
|
- name: local-agent
|
||||||
label:
|
label:
|
||||||
en_US: Local Agent
|
en_US: Local Agent
|
||||||
@@ -59,8 +59,11 @@ stages:
|
|||||||
label:
|
label:
|
||||||
en_US: Model
|
en_US: Model
|
||||||
zh_Hans: 模型
|
zh_Hans: 模型
|
||||||
type: llm-model-selector
|
type: model-fallback-selector
|
||||||
required: true
|
required: true
|
||||||
|
default:
|
||||||
|
primary: ''
|
||||||
|
fallbacks: []
|
||||||
- name: max-round
|
- name: max-round
|
||||||
label:
|
label:
|
||||||
en_US: Max Round
|
en_US: Max Round
|
||||||
@@ -71,6 +74,10 @@ stages:
|
|||||||
type: integer
|
type: integer
|
||||||
required: true
|
required: true
|
||||||
default: 10
|
default: 10
|
||||||
|
show_if:
|
||||||
|
field: __system.is_wizard
|
||||||
|
operator: neq
|
||||||
|
value: true
|
||||||
- name: prompt
|
- name: prompt
|
||||||
label:
|
label:
|
||||||
en_US: Prompt
|
en_US: Prompt
|
||||||
@@ -80,6 +87,9 @@ stages:
|
|||||||
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
|
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
|
||||||
type: prompt-editor
|
type: prompt-editor
|
||||||
required: true
|
required: true
|
||||||
|
default:
|
||||||
|
- role: system
|
||||||
|
content: "You are a helpful assistant."
|
||||||
- name: knowledge-bases
|
- name: knowledge-bases
|
||||||
label:
|
label:
|
||||||
en_US: Knowledge Bases
|
en_US: Knowledge Bases
|
||||||
@@ -90,26 +100,10 @@ stages:
|
|||||||
type: knowledge-base-multi-selector
|
type: knowledge-base-multi-selector
|
||||||
required: false
|
required: false
|
||||||
default: []
|
default: []
|
||||||
- name: tbox-app-api
|
show_if:
|
||||||
label:
|
field: __system.is_wizard
|
||||||
en_US: Tbox App API
|
operator: neq
|
||||||
zh_Hans: 蚂蚁百宝箱平台 API
|
value: true
|
||||||
description:
|
|
||||||
en_US: Configure the Tbox App API of the pipeline
|
|
||||||
zh_Hans: 配置蚂蚁百宝箱平台 API
|
|
||||||
config:
|
|
||||||
- name: api-key
|
|
||||||
label:
|
|
||||||
en_US: API Key
|
|
||||||
zh_Hans: API 密钥
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
- name: app-id
|
|
||||||
label:
|
|
||||||
en_US: App ID
|
|
||||||
zh_Hans: 应用 ID
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
- name: dify-service-api
|
- name: dify-service-api
|
||||||
label:
|
label:
|
||||||
en_US: Dify Service API
|
en_US: Dify Service API
|
||||||
@@ -124,6 +118,12 @@ stages:
|
|||||||
zh_Hans: 基础 URL
|
zh_Hans: 基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
|
options:
|
||||||
|
- name: 'https://api.dify.ai/v1'
|
||||||
|
label:
|
||||||
|
en_US: Dify Cloud
|
||||||
|
zh_Hans: Dify 云服务
|
||||||
|
default: 'https://api.dify.ai/v1'
|
||||||
- name: base-prompt
|
- name: base-prompt
|
||||||
label:
|
label:
|
||||||
en_US: Base PROMPT
|
en_US: Base PROMPT
|
||||||
@@ -160,52 +160,7 @@ stages:
|
|||||||
zh_Hans: API 密钥
|
zh_Hans: API 密钥
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
- name: dashscope-app-api
|
default: 'your-api-key'
|
||||||
label:
|
|
||||||
en_US: Aliyun Dashscope App API
|
|
||||||
zh_Hans: 阿里云百炼平台 API
|
|
||||||
description:
|
|
||||||
en_US: Configure the Aliyun Dashscope App API of the pipeline
|
|
||||||
zh_Hans: 配置阿里云百炼平台 API
|
|
||||||
config:
|
|
||||||
- name: app-type
|
|
||||||
label:
|
|
||||||
en_US: App Type
|
|
||||||
zh_Hans: 应用类型
|
|
||||||
type: select
|
|
||||||
required: true
|
|
||||||
default: agent
|
|
||||||
options:
|
|
||||||
- name: agent
|
|
||||||
label:
|
|
||||||
en_US: Agent
|
|
||||||
zh_Hans: Agent
|
|
||||||
- name: workflow
|
|
||||||
label:
|
|
||||||
en_US: Workflow
|
|
||||||
zh_Hans: 工作流
|
|
||||||
- name: api-key
|
|
||||||
label:
|
|
||||||
en_US: API Key
|
|
||||||
zh_Hans: API 密钥
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
- name: app-id
|
|
||||||
label:
|
|
||||||
en_US: App ID
|
|
||||||
zh_Hans: 应用 ID
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
- name: references_quote
|
|
||||||
label:
|
|
||||||
en_US: References Quote
|
|
||||||
zh_Hans: 引用文本
|
|
||||||
description:
|
|
||||||
en_US: The text prompt when the references are included
|
|
||||||
zh_Hans: 包含引用资料时的文本提示
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: '参考资料来自:'
|
|
||||||
- name: n8n-service-api
|
- name: n8n-service-api
|
||||||
label:
|
label:
|
||||||
en_US: n8n Workflow API
|
en_US: n8n Workflow API
|
||||||
@@ -223,6 +178,7 @@ stages:
|
|||||||
zh_Hans: n8n 工作流的 webhook URL
|
zh_Hans: n8n 工作流的 webhook URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
|
default: 'http://your-n8n-webhook-url'
|
||||||
- name: auth-type
|
- name: auth-type
|
||||||
label:
|
label:
|
||||||
en_US: Authentication Type
|
en_US: Authentication Type
|
||||||
@@ -260,6 +216,10 @@ stages:
|
|||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
|
show_if:
|
||||||
|
field: auth-type
|
||||||
|
operator: eq
|
||||||
|
value: 'basic'
|
||||||
- name: basic-password
|
- name: basic-password
|
||||||
label:
|
label:
|
||||||
en_US: Password
|
en_US: Password
|
||||||
@@ -270,6 +230,10 @@ stages:
|
|||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
|
show_if:
|
||||||
|
field: auth-type
|
||||||
|
operator: eq
|
||||||
|
value: 'basic'
|
||||||
- name: jwt-secret
|
- name: jwt-secret
|
||||||
label:
|
label:
|
||||||
en_US: Secret
|
en_US: Secret
|
||||||
@@ -280,6 +244,10 @@ stages:
|
|||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
|
show_if:
|
||||||
|
field: auth-type
|
||||||
|
operator: eq
|
||||||
|
value: 'jwt'
|
||||||
- name: jwt-algorithm
|
- name: jwt-algorithm
|
||||||
label:
|
label:
|
||||||
en_US: Algorithm
|
en_US: Algorithm
|
||||||
@@ -290,6 +258,10 @@ stages:
|
|||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: 'HS256'
|
default: 'HS256'
|
||||||
|
show_if:
|
||||||
|
field: auth-type
|
||||||
|
operator: eq
|
||||||
|
value: 'jwt'
|
||||||
- name: header-name
|
- name: header-name
|
||||||
label:
|
label:
|
||||||
en_US: Header Name
|
en_US: Header Name
|
||||||
@@ -300,6 +272,10 @@ stages:
|
|||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
|
show_if:
|
||||||
|
field: auth-type
|
||||||
|
operator: eq
|
||||||
|
value: 'header'
|
||||||
- name: header-value
|
- name: header-value
|
||||||
label:
|
label:
|
||||||
en_US: Header Value
|
en_US: Header Value
|
||||||
@@ -310,6 +286,10 @@ stages:
|
|||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ''
|
default: ''
|
||||||
|
show_if:
|
||||||
|
field: auth-type
|
||||||
|
operator: eq
|
||||||
|
value: 'header'
|
||||||
- name: timeout
|
- name: timeout
|
||||||
label:
|
label:
|
||||||
en_US: Timeout
|
en_US: Timeout
|
||||||
@@ -330,6 +310,140 @@ stages:
|
|||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: 'response'
|
default: 'response'
|
||||||
|
- name: coze-api
|
||||||
|
label:
|
||||||
|
en_US: coze API
|
||||||
|
zh_Hans: 扣子 API
|
||||||
|
description:
|
||||||
|
en_US: Configure the Coze API of the pipeline
|
||||||
|
zh_Hans: 配置Coze API
|
||||||
|
config:
|
||||||
|
- name: api-key
|
||||||
|
label:
|
||||||
|
en_US: API Key
|
||||||
|
zh_Hans: API 密钥
|
||||||
|
description:
|
||||||
|
en_US: The API key for the Coze server
|
||||||
|
zh_Hans: Coze服务器的 API 密钥
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ''
|
||||||
|
- name: bot-id
|
||||||
|
label:
|
||||||
|
en_US: Bot ID
|
||||||
|
zh_Hans: 机器人 ID
|
||||||
|
description:
|
||||||
|
en_US: The ID of the bot to run
|
||||||
|
zh_Hans: 要运行的机器人 ID
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ''
|
||||||
|
- name: api-base
|
||||||
|
label:
|
||||||
|
en_US: API Base URL
|
||||||
|
zh_Hans: API 基础 URL
|
||||||
|
description:
|
||||||
|
en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
|
||||||
|
zh_Hans: Coze API 的基础 URL,请使用 https://api.coze.com 用于全球 Coze 版(coze.com)
|
||||||
|
type: string
|
||||||
|
options:
|
||||||
|
- name: 'https://api.coze.cn'
|
||||||
|
label:
|
||||||
|
en_US: Coze China
|
||||||
|
zh_Hans: Coze 中国版
|
||||||
|
- name: 'https://api.coze.com'
|
||||||
|
label:
|
||||||
|
en_US: Coze Global
|
||||||
|
zh_Hans: Coze 全球版
|
||||||
|
default: "https://api.coze.cn"
|
||||||
|
- name: auto-save-history
|
||||||
|
label:
|
||||||
|
en_US: Auto Save History
|
||||||
|
zh_Hans: 自动保存历史
|
||||||
|
description:
|
||||||
|
en_US: Whether to automatically save conversation history
|
||||||
|
zh_Hans: 是否自动保存对话历史
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
- name: timeout
|
||||||
|
label:
|
||||||
|
en_US: Request Timeout
|
||||||
|
zh_Hans: 请求超时
|
||||||
|
description:
|
||||||
|
en_US: Timeout in seconds for API requests
|
||||||
|
zh_Hans: API 请求超时时间(秒)
|
||||||
|
type: number
|
||||||
|
default: 120
|
||||||
|
- name: tbox-app-api
|
||||||
|
label:
|
||||||
|
en_US: Tbox App API
|
||||||
|
zh_Hans: 蚂蚁百宝箱平台 API
|
||||||
|
description:
|
||||||
|
en_US: Configure the Tbox App API of the pipeline
|
||||||
|
zh_Hans: 配置蚂蚁百宝箱平台 API
|
||||||
|
config:
|
||||||
|
- name: api-key
|
||||||
|
label:
|
||||||
|
en_US: API Key
|
||||||
|
zh_Hans: API 密钥
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ''
|
||||||
|
- name: app-id
|
||||||
|
label:
|
||||||
|
en_US: App ID
|
||||||
|
zh_Hans: 应用 ID
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ''
|
||||||
|
- name: dashscope-app-api
|
||||||
|
label:
|
||||||
|
en_US: Aliyun Dashscope App API
|
||||||
|
zh_Hans: 阿里云百炼平台 API
|
||||||
|
description:
|
||||||
|
en_US: Configure the Aliyun Dashscope App API of the pipeline
|
||||||
|
zh_Hans: 配置阿里云百炼平台 API
|
||||||
|
config:
|
||||||
|
- name: app-type
|
||||||
|
label:
|
||||||
|
en_US: App Type
|
||||||
|
zh_Hans: 应用类型
|
||||||
|
type: select
|
||||||
|
required: true
|
||||||
|
default: agent
|
||||||
|
options:
|
||||||
|
- name: agent
|
||||||
|
label:
|
||||||
|
en_US: Agent
|
||||||
|
zh_Hans: Agent
|
||||||
|
- name: workflow
|
||||||
|
label:
|
||||||
|
en_US: Workflow
|
||||||
|
zh_Hans: 工作流
|
||||||
|
- name: api-key
|
||||||
|
label:
|
||||||
|
en_US: API Key
|
||||||
|
zh_Hans: API 密钥
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: 'your-api-key'
|
||||||
|
- name: app-id
|
||||||
|
label:
|
||||||
|
en_US: App ID
|
||||||
|
zh_Hans: 应用 ID
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: 'your-app-id'
|
||||||
|
- name: references_quote
|
||||||
|
label:
|
||||||
|
en_US: References Quote
|
||||||
|
zh_Hans: 引用文本
|
||||||
|
description:
|
||||||
|
en_US: The text prompt when the references are included
|
||||||
|
zh_Hans: 包含引用资料时的文本提示
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: '参考资料来自:'
|
||||||
- name: langflow-api
|
- name: langflow-api
|
||||||
label:
|
label:
|
||||||
en_US: Langflow API
|
en_US: Langflow API
|
||||||
@@ -347,6 +461,7 @@ stages:
|
|||||||
zh_Hans: Langflow 服务器的基础 URL
|
zh_Hans: Langflow 服务器的基础 URL
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
|
default: 'http://localhost:7860'
|
||||||
- name: api-key
|
- name: api-key
|
||||||
label:
|
label:
|
||||||
en_US: API Key
|
en_US: API Key
|
||||||
@@ -356,6 +471,7 @@ stages:
|
|||||||
zh_Hans: Langflow 服务器的 API 密钥
|
zh_Hans: Langflow 服务器的 API 密钥
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
|
default: 'your-api-key'
|
||||||
- name: flow-id
|
- name: flow-id
|
||||||
label:
|
label:
|
||||||
en_US: Flow ID
|
en_US: Flow ID
|
||||||
@@ -365,6 +481,7 @@ stages:
|
|||||||
zh_Hans: 要运行的流程 ID
|
zh_Hans: 要运行的流程 ID
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: true
|
||||||
|
default: 'your-flow-id'
|
||||||
- name: input-type
|
- name: input-type
|
||||||
label:
|
label:
|
||||||
en_US: Input Type
|
en_US: Input Type
|
||||||
@@ -394,57 +511,4 @@ stages:
|
|||||||
zh_Hans: 可选的流程调整参数
|
zh_Hans: 可选的流程调整参数
|
||||||
type: json
|
type: json
|
||||||
required: false
|
required: false
|
||||||
default: '{}'
|
default: '{}'
|
||||||
- name: coze-api
|
|
||||||
label:
|
|
||||||
en_US: coze API
|
|
||||||
zh_Hans: 扣子 API
|
|
||||||
description:
|
|
||||||
en_US: Configure the Coze API of the pipeline
|
|
||||||
zh_Hans: 配置Coze API
|
|
||||||
config:
|
|
||||||
- name: api-key
|
|
||||||
label:
|
|
||||||
en_US: API Key
|
|
||||||
zh_Hans: API 密钥
|
|
||||||
description:
|
|
||||||
en_US: The API key for the Coze server
|
|
||||||
zh_Hans: Coze服务器的 API 密钥
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
- name: bot-id
|
|
||||||
label:
|
|
||||||
en_US: Bot ID
|
|
||||||
zh_Hans: 机器人 ID
|
|
||||||
description:
|
|
||||||
en_US: The ID of the bot to run
|
|
||||||
zh_Hans: 要运行的机器人 ID
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
- name: api-base
|
|
||||||
label:
|
|
||||||
en_US: API Base URL
|
|
||||||
zh_Hans: API 基础 URL
|
|
||||||
description:
|
|
||||||
en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
|
|
||||||
zh_Hans: Coze API 的基础 URL,请使用 https://api.coze.com 用于全球 Coze 版(coze.com)
|
|
||||||
type: string
|
|
||||||
default: "https://api.coze.cn"
|
|
||||||
- name: auto-save-history
|
|
||||||
label:
|
|
||||||
en_US: Auto Save History
|
|
||||||
zh_Hans: 自动保存历史
|
|
||||||
description:
|
|
||||||
en_US: Whether to automatically save conversation history
|
|
||||||
zh_Hans: 是否自动保存对话历史
|
|
||||||
type: boolean
|
|
||||||
default: true
|
|
||||||
- name: timeout
|
|
||||||
label:
|
|
||||||
en_US: Request Timeout
|
|
||||||
zh_Hans: 请求超时
|
|
||||||
description:
|
|
||||||
en_US: Timeout in seconds for API requests
|
|
||||||
zh_Hans: API 请求超时时间(秒)
|
|
||||||
type: number
|
|
||||||
default: 120
|
|
||||||
@@ -37,10 +37,6 @@ stages:
|
|||||||
label:
|
label:
|
||||||
en_US: Convert to Image
|
en_US: Convert to Image
|
||||||
zh_Hans: 转换为图片
|
zh_Hans: 转换为图片
|
||||||
- name: split
|
|
||||||
label:
|
|
||||||
en_US: Split into Multiple Messages
|
|
||||||
zh_Hans: 分割为多条消息发送
|
|
||||||
- name: none
|
- name: none
|
||||||
label:
|
label:
|
||||||
en_US: None
|
en_US: None
|
||||||
@@ -82,13 +78,39 @@ stages:
|
|||||||
en_US: Misc
|
en_US: Misc
|
||||||
zh_Hans: 杂项
|
zh_Hans: 杂项
|
||||||
config:
|
config:
|
||||||
- name: hide-exception
|
- name: exception-handling
|
||||||
label:
|
label:
|
||||||
en_US: Hide Exception
|
en_US: Exception Handling Strategy
|
||||||
zh_Hans: 不输出异常信息给用户
|
zh_Hans: 异常处理策略
|
||||||
type: boolean
|
description:
|
||||||
|
en_US: Controls how error messages are displayed to the user when an AI request fails
|
||||||
|
zh_Hans: 控制 AI 请求失败时向用户展示错误信息的方式
|
||||||
|
type: select
|
||||||
required: true
|
required: true
|
||||||
default: true
|
default: show-hint
|
||||||
|
options:
|
||||||
|
- name: show-error
|
||||||
|
label:
|
||||||
|
en_US: Show Full Error
|
||||||
|
zh_Hans: 显示完整报错信息
|
||||||
|
- name: show-hint
|
||||||
|
label:
|
||||||
|
en_US: Show Failure Hint
|
||||||
|
zh_Hans: 仅文字提示
|
||||||
|
- name: hide
|
||||||
|
label:
|
||||||
|
en_US: Hide All
|
||||||
|
zh_Hans: 不显示任何异常信息
|
||||||
|
- name: failure-hint
|
||||||
|
label:
|
||||||
|
en_US: Failure Hint Text
|
||||||
|
zh_Hans: 失败提示文本
|
||||||
|
description:
|
||||||
|
en_US: The text to display when a request fails. Only effective when Exception Handling Strategy is set to "Show Failure Hint"
|
||||||
|
zh_Hans: 请求失败时显示的提示文本,仅在异常处理策略设置为"仅文字提示"时生效
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: 'Request failed.'
|
||||||
- name: at-sender
|
- name: at-sender
|
||||||
label:
|
label:
|
||||||
en_US: At Sender
|
en_US: At Sender
|
||||||
@@ -123,3 +145,4 @@ stages:
|
|||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user