mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 20:14:36 +00:00
Compare commits
33 Commits
fix/wecomb
...
feat/human
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3d366b569 | ||
|
|
db68c5d0c9 | ||
|
|
8f1317b39e | ||
|
|
77a0de5ef0 | ||
|
|
875227a2fe | ||
|
|
2317392ee5 | ||
|
|
c7efa4dd7f | ||
|
|
e701daa8e0 | ||
|
|
1ae99199b2 | ||
|
|
7c067a1cb3 | ||
|
|
478bc62576 | ||
|
|
a740eb8ee9 | ||
|
|
f8aedd02b3 | ||
|
|
ea638cab80 | ||
|
|
7129dd536e | ||
|
|
1b1cc7769b | ||
|
|
44b8354dfd | ||
|
|
55ec9d11ae | ||
|
|
5b3d3801b5 | ||
|
|
9f1ea75d09 | ||
|
|
6e37aae636 | ||
|
|
921d12f596 | ||
|
|
6bf6deaefd | ||
|
|
1201949f2c | ||
|
|
1c419e3591 | ||
|
|
b0a9be77b0 | ||
|
|
e02ade5a30 | ||
|
|
1a51ba8e7e | ||
|
|
e7b22d6ebf | ||
|
|
dddfa8ac79 | ||
|
|
99e2976826 | ||
|
|
71e44f0e54 | ||
|
|
4c904c2375 |
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -1,5 +1,5 @@
|
||||
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]: "
|
||||
labels: ["bug?"]
|
||||
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
|
||||
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]: "
|
||||
labels: ["bug?"]
|
||||
body:
|
||||
|
||||
@@ -43,10 +43,10 @@ jobs:
|
||||
run: |
|
||||
cd /tmp/langbot_build_web/web
|
||||
npm install
|
||||
npm run build
|
||||
npx vite build
|
||||
- name: Package Output
|
||||
run: |
|
||||
cp -r /tmp/langbot_build_web/web/out ./web
|
||||
cp -r /tmp/langbot_build_web/web/dist ./web
|
||||
- name: Upload Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
4
.github/workflows/publish-to-pypi.yml
vendored
4
.github/workflows/publish-to-pypi.yml
vendored
@@ -29,8 +29,8 @@ jobs:
|
||||
npm install -g pnpm
|
||||
pnpm install
|
||||
pnpm build
|
||||
mkdir -p ../src/langbot/web/out
|
||||
cp -r out ../src/langbot/web/
|
||||
mkdir -p ../src/langbot/web/dist
|
||||
cp -r dist ../src/langbot/web/
|
||||
|
||||
- name: Install the latest version of uv
|
||||
uses: astral-sh/setup-uv@v6
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -52,3 +52,6 @@ src/langbot/web/
|
||||
/dist
|
||||
/build
|
||||
*.egg-info
|
||||
|
||||
# Next.js build cache (legacy)
|
||||
web/.next/
|
||||
|
||||
@@ -4,7 +4,7 @@ WORKDIR /app
|
||||
|
||||
COPY web ./web
|
||||
|
||||
RUN cd web && npm install && npm run build
|
||||
RUN cd web && npm install && npx vite build
|
||||
|
||||
FROM python:3.12.7-slim
|
||||
|
||||
@@ -12,7 +12,7 @@ WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
COPY --from=node /app/web/out ./web/out
|
||||
COPY --from=node /app/web/dist ./web/dist
|
||||
|
||||
RUN apt update \
|
||||
&& apt install gcc -y \
|
||||
|
||||
12
README.md
12
README.md
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Website</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/features">Features</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide">Docs</a> |
|
||||
<a href="https://docs.langbot.app/en/tags/readme">API</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Features</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Docs</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">Plugin Market</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.
|
||||
- **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://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 | ✅ |
|
||||
| [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)
|
||||
|
||||
---
|
||||
|
||||
|
||||
12
README_CN.md
12
README_CN.md
@@ -21,9 +21,9 @@
|
||||
[](https://gitcode.com/RockChinQ/LangBot)
|
||||
|
||||
<a href="https://langbot.app">官网</a> |
|
||||
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a> |
|
||||
<a href="https://docs.langbot.app/zh/insight/guide.html">文档</a> |
|
||||
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/guide">文档</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">插件市场</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">路线图</a>
|
||||
@@ -45,7 +45,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
||||
- **Web 管理面板** — 通过浏览器直观地配置、管理和监控机器人,无需手动编辑配置文件。
|
||||
- **多流水线架构** — 不同机器人用于不同场景,具备全面的监控和异常处理能力。
|
||||
|
||||
[→ 了解更多功能特性](https://docs.langbot.app/zh/insight/features.html)
|
||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||
|
||||
---
|
||||
|
||||
@@ -76,7 +76,7 @@ docker compose up -d
|
||||
[](https://zeabur.com/zh-CN/templates/ZKTBDH)
|
||||
[](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)
|
||||
|
||||
---
|
||||
|
||||
@@ -125,7 +125,7 @@ docker compose up -d
|
||||
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||
|
||||
[→ 查看完整集成列表](https://docs.langbot.app/zh/insight/features.html)
|
||||
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||
|
||||
### TTS(语音合成)
|
||||
|
||||
|
||||
12
README_ES.md
12
README_ES.md
@@ -19,9 +19,9 @@
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<a href="https://langbot.app">Inicio</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/features.html">Características</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">Documentación</a> |
|
||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Características</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Documentación</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://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.
|
||||
- **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://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 | ✅ |
|
||||
| [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)
|
||||
|
||||
<a href="https://langbot.app">Accueil</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/features.html">Fonctionnalités</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">Documentation</a> |
|
||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Fonctionnalités</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Documentation</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://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.
|
||||
- **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://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 | ✅ |
|
||||
| [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)
|
||||
|
||||
<a href="https://langbot.app">ホーム</a> |
|
||||
<a href="https://docs.langbot.app/ja/insight/features.html">機能</a> |
|
||||
<a href="https://docs.langbot.app/ja/insight/guide.html">ドキュメント</a> |
|
||||
<a href="https://docs.langbot.app/ja/tags/readme.html">API</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/features">機能</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/guide">ドキュメント</a> |
|
||||
<a href="https://link.langbot.app/ja/docs/api">API</a> |
|
||||
<a href="https://space.langbot.app">プラグインマーケット</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">ロードマップ</a>
|
||||
|
||||
@@ -44,7 +44,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
||||
- **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://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/) | ゲートウェイ | ✅ |
|
||||
| [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)
|
||||
|
||||
<a href="https://langbot.app">홈</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/features.html">기능</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">문서</a> |
|
||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">기능</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">문서</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://space.langbot.app">플러그인 마켓</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">로드맵</a>
|
||||
|
||||
@@ -44,7 +44,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
||||
- **웹 관리 패널** — 직관적인 브라우저 인터페이스로 봇을 구성, 관리 및 모니터링. 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://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/) | 게이트웨이 | ✅ |
|
||||
| [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)
|
||||
|
||||
<a href="https://langbot.app">Главная</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/features.html">Возможности</a> |
|
||||
<a href="https://docs.langbot.app/en/insight/guide.html">Документация</a> |
|
||||
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> |
|
||||
<a href="https://link.langbot.app/en/docs/features">Возможности</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Документация</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://space.langbot.app">Магазин плагинов</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">Дорожная карта</a>
|
||||
|
||||
@@ -44,7 +44,7 @@ LangBot — это **платформа с открытым исходным к
|
||||
- **Веб-панель управления** — Настраивайте, управляйте и мониторьте ваших ботов через интуитивный браузерный интерфейс. Ручное редактирование 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://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 | ✅ |
|
||||
| [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)
|
||||
|
||||
<a href="https://langbot.app">官網</a> |
|
||||
<a href="https://docs.langbot.app/zh/insight/features.html">特性</a> |
|
||||
<a href="https://docs.langbot.app/zh/insight/guide.html">文件</a> |
|
||||
<a href="https://docs.langbot.app/zh/tags/readme.html">API</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/features">特性</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/guide">文件</a> |
|
||||
<a href="https://link.langbot.app/zh/docs/api">API</a> |
|
||||
<a href="https://space.langbot.app">外掛市場</a> |
|
||||
<a href="https://langbot.featurebase.app/roadmap">路線圖</a>
|
||||
|
||||
@@ -46,7 +46,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
||||
- **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://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://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)
|
||||
|
||||
<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://docs.langbot.app/en/insight/guide.html">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/features">Tính năng</a> |
|
||||
<a href="https://link.langbot.app/en/docs/guide">Tài liệu</a> |
|
||||
<a href="https://link.langbot.app/en/docs/api">API</a> |
|
||||
<a href="https://space.langbot.app">Chợ Plugin</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.
|
||||
- **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://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 | ✅ |
|
||||
| [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)
|
||||
- [Docker 部署文档](https://docs.langbot.app/zh/deploy/langbot/docker.html)
|
||||
- [Docker 部署文档](https://link.langbot.app/zh/docs/docker)
|
||||
- [Kubernetes 官方文档](https://kubernetes.io/docs/)
|
||||
|
||||
---
|
||||
@@ -625,5 +625,5 @@ spec:
|
||||
### References
|
||||
|
||||
- [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/)
|
||||
|
||||
@@ -34,4 +34,4 @@ services:
|
||||
|
||||
networks:
|
||||
langbot_network:
|
||||
driver: bridge
|
||||
driver: bridge
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.9.4"
|
||||
version = "4.9.5"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -64,7 +64,7 @@ dependencies = [
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.3.5",
|
||||
"langbot-plugin==0.3.7",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
@@ -111,7 +111,7 @@ requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/out/**"] }
|
||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**"] }
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.9.4'
|
||||
__version__ = '4.9.5'
|
||||
|
||||
@@ -64,6 +64,9 @@ class StreamSession:
|
||||
# 缓存最近一次片段,处理重试或超时兜底
|
||||
last_chunk: Optional[StreamChunk] = None
|
||||
|
||||
# 反馈 ID,用于接收用户点赞/点踩反馈
|
||||
feedback_id: Optional[str] = None
|
||||
|
||||
|
||||
class StreamSessionManager:
|
||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
||||
@@ -74,6 +77,7 @@ class StreamSessionManager:
|
||||
self.ttl = ttl # 超时时间(秒),超过该时间未被访问的会话会被清理由 cleanup
|
||||
self._sessions: dict[str, StreamSession] = {} # stream_id -> StreamSession 映射
|
||||
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]:
|
||||
if not msg_id:
|
||||
@@ -83,6 +87,32 @@ class StreamSessionManager:
|
||||
def get_session(self, stream_id: str) -> Optional[StreamSession]:
|
||||
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]:
|
||||
"""根据企业微信回调创建或获取会话。
|
||||
|
||||
@@ -404,10 +434,10 @@ async def parse_wecom_bot_message(
|
||||
}
|
||||
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
|
||||
# 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')
|
||||
@@ -419,10 +449,12 @@ async def parse_wecom_bot_message(
|
||||
'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
|
||||
# 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
|
||||
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||
video_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||
message_data['video'] = video_data
|
||||
elif msg_type == 'file':
|
||||
file_info = msg_json.get('file', {}) or {}
|
||||
@@ -436,12 +468,15 @@ async def parse_wecom_bot_message(
|
||||
'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
|
||||
# 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
|
||||
|
||||
# 应为需要解密,但是目前暂时不能下载到内部进行解密,所以先将下载链接拼接aeskey返回给用户,由插件去处理该链接的下载和解密逻辑
|
||||
file_data['download_url'] = download_url + f'?aeskey={per_msg_aeskey}'
|
||||
message_data['file'] = file_data
|
||||
elif msg_type == 'link':
|
||||
message_data['link'] = msg_json.get('link', {})
|
||||
@@ -597,14 +632,27 @@ class WecomBotClient:
|
||||
self.stream_sessions = StreamSessionManager(logger=logger)
|
||||
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
|
||||
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:
|
||||
stream_id: 企业微信会话 ID。
|
||||
content: 推送的文本内容。
|
||||
finish: 是否为最终片段。
|
||||
feedback_id: 反馈 ID,用于接收用户点赞/点踩反馈。
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: 可直接加密返回的 payload。
|
||||
@@ -612,13 +660,16 @@ class WecomBotClient:
|
||||
Example:
|
||||
组装 `{'msgtype': 'stream', 'stream': {'id': 'sid', ...}}` 结构。
|
||||
"""
|
||||
stream_payload = {
|
||||
'id': stream_id,
|
||||
'finish': finish,
|
||||
'content': content,
|
||||
}
|
||||
if feedback_id:
|
||||
stream_payload['feedback'] = {'id': feedback_id}
|
||||
return {
|
||||
'msgtype': 'stream',
|
||||
'stream': {
|
||||
'id': stream_id,
|
||||
'finish': finish,
|
||||
'content': content,
|
||||
},
|
||||
'stream': stream_payload,
|
||||
}
|
||||
|
||||
async def _encrypt_and_reply(self, payload: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||
@@ -674,9 +725,14 @@ class WecomBotClient:
|
||||
"""
|
||||
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)
|
||||
if message_data:
|
||||
message_data['stream_id'] = session.stream_id
|
||||
message_data['feedback_id'] = feedback_id
|
||||
try:
|
||||
event = wecombotevent.WecomBotEvent(message_data)
|
||||
except Exception:
|
||||
@@ -685,7 +741,7 @@ class WecomBotClient:
|
||||
if is_new:
|
||||
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)
|
||||
|
||||
async def _handle_post_followup_response(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||
@@ -810,11 +866,78 @@ class WecomBotClient:
|
||||
|
||||
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':
|
||||
return await self._handle_post_followup_response(msg_json, nonce)
|
||||
|
||||
return await self._handle_post_initial_response(msg_json, nonce)
|
||||
|
||||
async def _handle_feedback_event(self, msg_json: dict[str, Any], nonce: str) -> tuple[Response, int]:
|
||||
"""处理企业微信用户反馈事件(点赞/点踩)。
|
||||
|
||||
Args:
|
||||
msg_json: 解密后的企业微信反馈事件 JSON。
|
||||
nonce: 企业微信回调参数 nonce。
|
||||
|
||||
Returns:
|
||||
Tuple[Response, int]: Quart Response 及状态码。
|
||||
|
||||
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', [])
|
||||
|
||||
await self.logger.info(
|
||||
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||
)
|
||||
|
||||
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
||||
if session:
|
||||
await self.logger.info(
|
||||
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
||||
)
|
||||
for handler in self._message_handlers.get('feedback', []):
|
||||
try:
|
||||
await handler(
|
||||
feedback_id=feedback_id,
|
||||
feedback_type=feedback_type,
|
||||
feedback_content=feedback_content,
|
||||
inaccurate_reasons=inaccurate_reasons,
|
||||
session=session,
|
||||
)
|
||||
except Exception:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
|
||||
if self._feedback_callback:
|
||||
try:
|
||||
await self._feedback_callback(
|
||||
feedback_id=feedback_id,
|
||||
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} 对应的会话')
|
||||
|
||||
except Exception:
|
||||
await self.logger.error(traceback.format_exc())
|
||||
|
||||
return await self._encrypt_and_reply({}, nonce)
|
||||
|
||||
async def get_message(self, msg_json):
|
||||
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
|
||||
|
||||
@@ -883,6 +1006,15 @@ class WecomBotClient:
|
||||
|
||||
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):
|
||||
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||
if data:
|
||||
|
||||
@@ -133,3 +133,17 @@ class WecomBotEvent(dict):
|
||||
AI Bot ID
|
||||
"""
|
||||
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', '')
|
||||
|
||||
@@ -20,7 +20,7 @@ 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.libs.wecom_ai_bot_api.api import parse_wecom_bot_message, StreamSession
|
||||
from langbot.pkg.platform.logger import EventLogger
|
||||
|
||||
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||
@@ -96,6 +96,12 @@ class WecomBotWsClient:
|
||||
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
|
||||
# Stream session info for feedback tracking
|
||||
self._stream_sessions: dict[str, dict] = {} # msg_id -> session info
|
||||
# Feedback tracking: feedback_id -> session info
|
||||
self._feedback_sessions: dict[str, dict] = {} # feedback_id -> {msg_id, user_id, chat_id, stream_id, req_id}
|
||||
# msg_id -> feedback_id (for associating feedback with message)
|
||||
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────
|
||||
|
||||
@@ -164,12 +170,27 @@ class WecomBotWsClient:
|
||||
|
||||
return decorator
|
||||
|
||||
def on_feedback(self) -> Callable:
|
||||
"""Decorator to register a feedback event handler.
|
||||
|
||||
Same interface as WecomBotClient.on_feedback for compatibility.
|
||||
"""
|
||||
|
||||
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 reply_stream(
|
||||
self,
|
||||
req_id: str,
|
||||
stream_id: str,
|
||||
content: str,
|
||||
finish: bool = False,
|
||||
feedback_id: str = '',
|
||||
) -> Optional[dict]:
|
||||
"""Send a streaming reply frame.
|
||||
|
||||
@@ -178,17 +199,22 @@ class WecomBotWsClient:
|
||||
stream_id: The stream ID for this streaming session.
|
||||
content: The content to send (supports Markdown).
|
||||
finish: Whether this is the final chunk.
|
||||
feedback_id: Optional feedback ID for receiving user feedback (like/dislike).
|
||||
|
||||
Returns:
|
||||
The ACK frame dict, or None on failure.
|
||||
"""
|
||||
stream_payload = {
|
||||
'id': stream_id,
|
||||
'finish': finish,
|
||||
'content': content,
|
||||
}
|
||||
if feedback_id:
|
||||
stream_payload['feedback'] = {'id': feedback_id}
|
||||
|
||||
body = {
|
||||
'msgtype': 'stream',
|
||||
'stream': {
|
||||
'id': stream_id,
|
||||
'finish': finish,
|
||||
'content': content,
|
||||
},
|
||||
'stream': stream_payload,
|
||||
}
|
||||
return await self._send_reply(req_id, body)
|
||||
|
||||
@@ -253,11 +279,23 @@ class WecomBotWsClient:
|
||||
# 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)
|
||||
|
||||
# Generate feedback_id for final chunk
|
||||
feedback_id = ''
|
||||
if is_final:
|
||||
feedback_id = _generate_req_id('feedback')
|
||||
self._msg_feedback_ids[msg_id] = feedback_id
|
||||
# Store session info for feedback tracking
|
||||
session_info = self._stream_sessions.get(msg_id)
|
||||
if session_info:
|
||||
self._feedback_sessions[feedback_id] = session_info
|
||||
|
||||
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
|
||||
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)
|
||||
self._stream_sessions.pop(msg_id, None)
|
||||
return True
|
||||
except Exception:
|
||||
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
||||
@@ -445,6 +483,15 @@ class WecomBotWsClient:
|
||||
msg_id = message_data.get('msgid', '')
|
||||
if msg_id:
|
||||
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
|
||||
# Store session info for feedback tracking
|
||||
self._stream_sessions[msg_id] = {
|
||||
'req_id': req_id,
|
||||
'stream_id': stream_id,
|
||||
'msg_id': msg_id,
|
||||
'user_id': message_data.get('userid', ''),
|
||||
'chat_id': message_data.get('chatid', ''),
|
||||
'chat_type': message_data.get('type', 'single'),
|
||||
}
|
||||
message_data['stream_id'] = stream_id
|
||||
message_data['req_id'] = req_id
|
||||
|
||||
@@ -454,7 +501,7 @@ class WecomBotWsClient:
|
||||
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.)."""
|
||||
"""Handle an incoming event callback frame (enter_chat, template_card_event, feedback_event, disconnected_event)."""
|
||||
try:
|
||||
body = frame.get('body', {})
|
||||
req_id = frame.get('headers', {}).get('req_id', '')
|
||||
@@ -479,14 +526,54 @@ class WecomBotWsClient:
|
||||
if body.get('chatid'):
|
||||
message_data['chatid'] = body.get('chatid', '')
|
||||
|
||||
if event_type == 'feedback_event':
|
||||
feedback_event = event_info.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', [])
|
||||
|
||||
await self.logger.info(
|
||||
f'收到用户反馈事件: feedback_id={feedback_id}, type={feedback_type}, '
|
||||
f'content={feedback_content}, reasons={inaccurate_reasons}'
|
||||
)
|
||||
|
||||
# Look up session by feedback_id
|
||||
session_info = self._feedback_sessions.get(feedback_id)
|
||||
session = None
|
||||
if session_info:
|
||||
session = StreamSession(
|
||||
stream_id=session_info.get('stream_id', ''),
|
||||
msg_id=session_info.get('msg_id', ''),
|
||||
chat_id=session_info.get('chat_id') or None,
|
||||
user_id=session_info.get('user_id') or None,
|
||||
feedback_id=feedback_id,
|
||||
)
|
||||
await self.logger.info(
|
||||
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
||||
)
|
||||
else:
|
||||
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
|
||||
|
||||
for handler in self._message_handlers.get('feedback', []):
|
||||
try:
|
||||
await handler(
|
||||
feedback_id=feedback_id,
|
||||
feedback_type=feedback_type,
|
||||
feedback_content=feedback_content,
|
||||
inaccurate_reasons=inaccurate_reasons,
|
||||
session=session,
|
||||
)
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in feedback handler: {traceback.format_exc()}')
|
||||
return
|
||||
|
||||
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)
|
||||
|
||||
97
src/langbot/pkg/api/http/controller/groups/human_takeover.py
Normal file
97
src/langbot/pkg/api/http/controller/groups/human_takeover.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('human-takeover', '/api/v1/human-takeover')
|
||||
class HumanTakeoverRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_sessions():
|
||||
"""Get list of takeover sessions, optionally filtered by bot UUID."""
|
||||
bot_uuid = quart.request.args.get('botUuid')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
|
||||
sessions, total = await self.ap.human_takeover_service.get_active_sessions(
|
||||
bot_uuid=bot_uuid if bot_uuid else None,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'sessions': sessions,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/sessions/<session_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_session_detail(session_id: str):
|
||||
"""Get detail for a specific takeover session."""
|
||||
detail = await self.ap.human_takeover_service.get_session_detail(session_id)
|
||||
if not detail:
|
||||
return self.success(data={'found': False, 'session_id': session_id})
|
||||
return self.success(data={'found': True, 'session': detail})
|
||||
|
||||
@self.route('/sessions/<session_id>/takeover', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def takeover_session(session_id: str, user_email: str = None):
|
||||
"""Take over a conversation session."""
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
|
||||
bot_uuid = data.get('bot_uuid')
|
||||
if not bot_uuid:
|
||||
return self.fail(-1, 'bot_uuid is required')
|
||||
|
||||
platform = data.get('platform')
|
||||
user_id = data.get('user_id')
|
||||
user_name = data.get('user_name')
|
||||
|
||||
try:
|
||||
result = await self.ap.human_takeover_service.takeover_session(
|
||||
session_id=session_id,
|
||||
bot_uuid=bot_uuid,
|
||||
taken_by=user_email or data.get('taken_by'),
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
)
|
||||
return self.success(data=result)
|
||||
except ValueError as e:
|
||||
return self.fail(-1, str(e))
|
||||
|
||||
@self.route('/sessions/<session_id>/release', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def release_session(session_id: str):
|
||||
"""Release a taken-over session back to AI pipeline."""
|
||||
try:
|
||||
result = await self.ap.human_takeover_service.release_session(session_id)
|
||||
return self.success(data=result)
|
||||
except ValueError as e:
|
||||
return self.fail(-1, str(e))
|
||||
|
||||
@self.route('/sessions/<session_id>/message', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def send_message(session_id: str, user_email: str = None):
|
||||
"""Send a message from the operator to the user."""
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
|
||||
message_text = data.get('message')
|
||||
if not message_text:
|
||||
return self.fail(-1, 'message is required')
|
||||
|
||||
operator_name = user_email or data.get('operator_name', 'Operator')
|
||||
|
||||
try:
|
||||
result = await self.ap.human_takeover_service.send_message(
|
||||
session_id=session_id,
|
||||
message_text=message_text,
|
||||
operator_name=operator_name,
|
||||
)
|
||||
return self.success(data=result)
|
||||
except ValueError as e:
|
||||
return self.fail(-1, str(e))
|
||||
except RuntimeError as e:
|
||||
return self.fail(-2, str(e))
|
||||
@@ -456,6 +456,31 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
'platform',
|
||||
'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:
|
||||
return self.error(message=f'Invalid export type: {export_type}', code=400)
|
||||
|
||||
@@ -486,3 +511,63 @@ class MonitoringRouterGroup(group.RouterGroup):
|
||||
)
|
||||
|
||||
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')
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
ctx.metadata['plugin_name'] = f'{owner}/{repo}'
|
||||
ctx.metadata['install_source'] = 'github'
|
||||
install_info = {
|
||||
'asset_url': asset_url,
|
||||
'owner': owner,
|
||||
@@ -295,12 +297,17 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
|
||||
data = await quart.request.json
|
||||
|
||||
plugin_author = data.get('plugin_author', '')
|
||||
plugin_name = data.get('plugin_name', '')
|
||||
|
||||
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(
|
||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.MARKETPLACE, data, task_context=ctx),
|
||||
kind='plugin-operation',
|
||||
name='plugin-install-marketplace',
|
||||
label=f'Installing plugin from marketplace ...{data}',
|
||||
label=f'Installing plugin from marketplace {plugin_author}/{plugin_name}',
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
@@ -323,11 +330,13 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
}
|
||||
|
||||
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(
|
||||
self.ap.plugin_connector.install_plugin(PluginInstallSource.LOCAL, data, task_context=ctx),
|
||||
kind='plugin-operation',
|
||||
name='plugin-install-local',
|
||||
label=f'Installing plugin from local ...{file.filename}',
|
||||
label=f'Installing plugin from local {file.filename}',
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ... import group
|
||||
|
||||
|
||||
@group.group_class('tools', '/api/v1/tools')
|
||||
class ToolsRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
"""获取所有可用工具列表"""
|
||||
tools = await self.ap.tool_mgr.get_all_tools()
|
||||
|
||||
tool_list = []
|
||||
for tool in tools:
|
||||
tool_list.append(
|
||||
{
|
||||
'name': tool.name,
|
||||
'description': tool.description,
|
||||
'human_desc': tool.human_desc,
|
||||
'parameters': tool.parameters,
|
||||
}
|
||||
)
|
||||
|
||||
return self.success(data={'tools': tool_list})
|
||||
|
||||
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _(tool_name: str) -> str:
|
||||
"""获取特定工具详情"""
|
||||
tools = await self.ap.tool_mgr.get_all_tools()
|
||||
|
||||
for tool in tools:
|
||||
if tool.name == tool_name:
|
||||
return self.success(
|
||||
data={
|
||||
'tool': {
|
||||
'name': tool.name,
|
||||
'description': tool.description,
|
||||
'human_desc': tool.human_desc,
|
||||
'parameters': tool.parameters,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return self.http_status(404, -1, f'Tool not found: {tool_name}')
|
||||
@@ -1,7 +1,11 @@
|
||||
import json
|
||||
|
||||
import quart
|
||||
import sqlalchemy
|
||||
|
||||
from .. import group
|
||||
from .....utils import constants
|
||||
from .....entity.persistence.metadata import Metadata
|
||||
|
||||
|
||||
@group.group_class('system', '/api/v1/system')
|
||||
@@ -9,6 +13,24 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/info', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
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(
|
||||
data={
|
||||
'version': constants.semantic_version,
|
||||
@@ -27,17 +49,83 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
'disable_models_service', False
|
||||
),
|
||||
'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)
|
||||
async def _() -> str:
|
||||
task_type = quart.request.args.get('type')
|
||||
task_kind = quart.request.args.get('kind')
|
||||
|
||||
if task_type == '':
|
||||
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)
|
||||
async def _(task_id: str) -> str:
|
||||
|
||||
314
src/langbot/pkg/api/http/service/human_takeover.py
Normal file
314
src/langbot/pkg/api/http/service/human_takeover.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import human_takeover as persistence_human_takeover
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
|
||||
class HumanTakeoverService:
|
||||
"""Human takeover service.
|
||||
|
||||
Manages operator takeover of user conversation sessions, bypassing
|
||||
the normal AI pipeline. Uses an in-memory cache for fast synchronous
|
||||
lookups on the hot message path, backed by database persistence.
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
# In-memory cache: session_id -> HumanTakeoverSession record id
|
||||
# Only contains sessions with status='active'
|
||||
_active_sessions: dict[str, str]
|
||||
|
||||
logger: logging.Logger
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
self._active_sessions = {}
|
||||
self.logger = logging.getLogger('human-takeover')
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Load active takeover sessions from DB into memory cache."""
|
||||
try:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).where(
|
||||
persistence_human_takeover.HumanTakeoverSession.status == 'active'
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
for row in rows:
|
||||
session = row[0] if isinstance(row, tuple) else row
|
||||
self._active_sessions[session.session_id] = session.id
|
||||
self.logger.info(f'Loaded {len(self._active_sessions)} active takeover sessions from DB')
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Failed to load active takeover sessions: {e}')
|
||||
|
||||
def is_taken_over(self, session_id: str) -> bool:
|
||||
"""Check if a session is currently under human takeover.
|
||||
|
||||
This is a synchronous in-memory lookup for performance, since it
|
||||
is called on every incoming message (hot path).
|
||||
"""
|
||||
return session_id in self._active_sessions
|
||||
|
||||
async def takeover_session(
|
||||
self,
|
||||
session_id: str,
|
||||
bot_uuid: str,
|
||||
taken_by: str | None = None,
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Take over a conversation session.
|
||||
|
||||
Args:
|
||||
session_id: The session to take over (e.g. 'person_123' or 'group_456').
|
||||
bot_uuid: UUID of the bot whose session is being taken over.
|
||||
taken_by: Email/username of the admin performing the takeover.
|
||||
platform: Platform name.
|
||||
user_id: The end-user's ID in the session.
|
||||
user_name: The end-user's display name.
|
||||
|
||||
Returns:
|
||||
Dict with the created takeover session record.
|
||||
|
||||
Raises:
|
||||
ValueError: If the session is already taken over.
|
||||
"""
|
||||
if self.is_taken_over(session_id):
|
||||
raise ValueError(f'Session {session_id} is already taken over')
|
||||
|
||||
record_id = str(uuid.uuid4())
|
||||
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
|
||||
record_data = {
|
||||
'id': record_id,
|
||||
'session_id': session_id,
|
||||
'bot_uuid': bot_uuid,
|
||||
'status': 'active',
|
||||
'taken_by': taken_by,
|
||||
'taken_at': now,
|
||||
'released_at': None,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'user_name': user_name,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_human_takeover.HumanTakeoverSession).values(record_data)
|
||||
)
|
||||
|
||||
# Update in-memory cache
|
||||
self._active_sessions[session_id] = record_id
|
||||
|
||||
self.logger.info(f'Session {session_id} taken over by {taken_by}')
|
||||
|
||||
return record_data
|
||||
|
||||
async def release_session(self, session_id: str) -> dict:
|
||||
"""Release a taken-over session back to AI pipeline processing.
|
||||
|
||||
Args:
|
||||
session_id: The session to release.
|
||||
|
||||
Returns:
|
||||
Dict with the updated takeover session record.
|
||||
|
||||
Raises:
|
||||
ValueError: If the session is not currently taken over.
|
||||
"""
|
||||
if not self.is_taken_over(session_id):
|
||||
raise ValueError(f'Session {session_id} is not currently taken over')
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_human_takeover.HumanTakeoverSession)
|
||||
.where(
|
||||
sqlalchemy.and_(
|
||||
persistence_human_takeover.HumanTakeoverSession.session_id == session_id,
|
||||
persistence_human_takeover.HumanTakeoverSession.status == 'active',
|
||||
)
|
||||
)
|
||||
.values(status='released', released_at=now)
|
||||
)
|
||||
|
||||
# Remove from in-memory cache
|
||||
self._active_sessions.pop(session_id, None)
|
||||
|
||||
self.logger.info(f'Session {session_id} released back to AI pipeline')
|
||||
|
||||
return {
|
||||
'session_id': session_id,
|
||||
'status': 'released',
|
||||
'released_at': now.isoformat(),
|
||||
}
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
session_id: str,
|
||||
message_text: str,
|
||||
operator_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Send a message from the operator to the user via the platform adapter.
|
||||
|
||||
Args:
|
||||
session_id: The taken-over session ID (e.g. 'person_123' or 'group_456').
|
||||
message_text: The text message to send.
|
||||
operator_name: Name of the operator sending the message.
|
||||
|
||||
Returns:
|
||||
Dict with send result info.
|
||||
|
||||
Raises:
|
||||
ValueError: If the session is not currently taken over.
|
||||
RuntimeError: If the bot or adapter cannot be found.
|
||||
"""
|
||||
if not self.is_taken_over(session_id):
|
||||
raise ValueError(f'Session {session_id} is not currently taken over')
|
||||
|
||||
# Look up the takeover record to get bot_uuid
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).where(
|
||||
sqlalchemy.and_(
|
||||
persistence_human_takeover.HumanTakeoverSession.session_id == session_id,
|
||||
persistence_human_takeover.HumanTakeoverSession.status == 'active',
|
||||
)
|
||||
)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise RuntimeError(f'Active takeover record not found for session {session_id}')
|
||||
|
||||
takeover_record = row[0] if isinstance(row, tuple) else row
|
||||
bot_uuid = takeover_record.bot_uuid
|
||||
|
||||
# Get the runtime bot
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
if not runtime_bot:
|
||||
raise RuntimeError(f'Bot {bot_uuid} not found or not running')
|
||||
|
||||
# Parse session_id to determine target_type and target_id
|
||||
# Format: 'person_{id}' or 'group_{id}'
|
||||
if session_id.startswith('person_'):
|
||||
target_type = 'person'
|
||||
target_id = session_id[len('person_') :]
|
||||
elif session_id.startswith('group_'):
|
||||
target_type = 'group'
|
||||
target_id = session_id[len('group_') :]
|
||||
else:
|
||||
raise ValueError(f'Invalid session_id format: {session_id}')
|
||||
|
||||
# Build message chain
|
||||
message_chain = platform_message.MessageChain([platform_message.Plain(text=message_text)])
|
||||
|
||||
# Send via adapter
|
||||
await runtime_bot.adapter.send_message(target_type, target_id, message_chain)
|
||||
|
||||
# Record the operator message in monitoring
|
||||
bot_name = runtime_bot.bot_entity.name or bot_uuid
|
||||
try:
|
||||
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
|
||||
except Exception:
|
||||
message_content = message_text
|
||||
|
||||
await self.ap.monitoring_service.record_message(
|
||||
bot_id=bot_uuid,
|
||||
bot_name=bot_name,
|
||||
pipeline_id='__human_takeover__',
|
||||
pipeline_name='Human Takeover',
|
||||
message_content=message_content,
|
||||
session_id=session_id,
|
||||
status='success',
|
||||
level='info',
|
||||
platform=takeover_record.platform,
|
||||
user_id=operator_name or 'operator',
|
||||
user_name=operator_name or 'Operator',
|
||||
role='operator',
|
||||
)
|
||||
|
||||
self.logger.info(f'Operator message sent to session {session_id}: {message_text[:50]}...')
|
||||
|
||||
return {
|
||||
'session_id': session_id,
|
||||
'message_sent': True,
|
||||
}
|
||||
|
||||
async def get_active_sessions(
|
||||
self,
|
||||
bot_uuid: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get list of active (or all) takeover sessions.
|
||||
|
||||
Args:
|
||||
bot_uuid: Optional filter by bot UUID.
|
||||
limit: Maximum number of results.
|
||||
offset: Pagination offset.
|
||||
|
||||
Returns:
|
||||
Tuple of (list of session dicts, total count).
|
||||
"""
|
||||
conditions = []
|
||||
|
||||
if bot_uuid:
|
||||
conditions.append(persistence_human_takeover.HumanTakeoverSession.bot_uuid == bot_uuid)
|
||||
|
||||
# Count
|
||||
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_human_takeover.HumanTakeoverSession.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
|
||||
|
||||
# Fetch records
|
||||
query = sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).order_by(
|
||||
persistence_human_takeover.HumanTakeoverSession.taken_at.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()
|
||||
|
||||
sessions = []
|
||||
for row in rows:
|
||||
session = row[0] if isinstance(row, tuple) else row
|
||||
sessions.append(
|
||||
self.ap.persistence_mgr.serialize_model(persistence_human_takeover.HumanTakeoverSession, session)
|
||||
)
|
||||
|
||||
return sessions, total
|
||||
|
||||
async def get_session_detail(self, session_id: str) -> dict | None:
|
||||
"""Get detail for a specific takeover session.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to look up.
|
||||
|
||||
Returns:
|
||||
Session dict or None if not found.
|
||||
"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession)
|
||||
.where(persistence_human_takeover.HumanTakeoverSession.session_id == session_id)
|
||||
.order_by(persistence_human_takeover.HumanTakeoverSession.taken_at.desc())
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
session = row[0] if isinstance(row, tuple) else row
|
||||
return self.ap.persistence_mgr.serialize_model(persistence_human_takeover.HumanTakeoverSession, session)
|
||||
@@ -1183,3 +1183,261 @@ class MonitoringService:
|
||||
}
|
||||
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
|
||||
]
|
||||
|
||||
@@ -31,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 webhook as webhook_service
|
||||
from ..api.http.service import monitoring as monitoring_service
|
||||
from ..api.http.service import human_takeover as human_takeover_service
|
||||
|
||||
from ..discover import engine as discover_engine
|
||||
from ..storage import mgr as storagemgr
|
||||
@@ -153,6 +154,8 @@ class Application:
|
||||
|
||||
monitoring_service: monitoring_service.MonitoringService = None
|
||||
|
||||
human_takeover_service: human_takeover_service.HumanTakeoverService = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from ...api.http.service import mcp as mcp_service
|
||||
from ...api.http.service import apikey as apikey_service
|
||||
from ...api.http.service import webhook as webhook_service
|
||||
from ...api.http.service import monitoring as monitoring_service
|
||||
from ...api.http.service import human_takeover as human_takeover_service
|
||||
from ...discover import engine as discover_engine
|
||||
from ...storage import mgr as storagemgr
|
||||
from ...utils import logcache
|
||||
@@ -164,6 +165,10 @@ class BuildAppStage(stage.BootingStage):
|
||||
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
||||
ap.monitoring_service = monitoring_service_inst
|
||||
|
||||
human_takeover_service_inst = human_takeover_service.HumanTakeoverService(ap)
|
||||
await human_takeover_service_inst.initialize()
|
||||
ap.human_takeover_service = human_takeover_service_inst
|
||||
|
||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||
await asyncio.sleep(3)
|
||||
await plugin_connector_inst.initialize()
|
||||
|
||||
@@ -80,8 +80,12 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
|
||||
if i == len(keys) - 1:
|
||||
# At the final key
|
||||
if key in current:
|
||||
if isinstance(current[key], (dict, list)):
|
||||
# Skip dict and list types
|
||||
if isinstance(current[key], list):
|
||||
# Convert comma-separated string to list
|
||||
# e.g., SYSTEM__DISABLED_ADAPTERS="aiocqhttp,dingtalk"
|
||||
current[key] = [item.strip() for item in env_value.split(',') if item.strip()]
|
||||
elif isinstance(current[key], dict):
|
||||
# Skip dict types
|
||||
pass
|
||||
else:
|
||||
# Valid scalar value - convert and set it
|
||||
|
||||
@@ -17,9 +17,13 @@ class TaskContext:
|
||||
log: str
|
||||
"""Log"""
|
||||
|
||||
metadata: dict
|
||||
"""Structured metadata for progress reporting"""
|
||||
|
||||
def __init__(self):
|
||||
self.current_action = 'default'
|
||||
self.log = ''
|
||||
self.metadata = {}
|
||||
|
||||
def _log(self, msg: str):
|
||||
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}')
|
||||
|
||||
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
|
||||
def new() -> TaskContext:
|
||||
@@ -211,9 +215,14 @@ class AsyncTaskManager:
|
||||
def get_tasks_dict(
|
||||
self,
|
||||
type: str = None,
|
||||
kind: str = None,
|
||||
) -> dict:
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@@ -17,11 +17,23 @@ class I18nString(pydantic.BaseModel):
|
||||
"""英文"""
|
||||
|
||||
zh_Hans: typing.Optional[str] = None
|
||||
"""中文"""
|
||||
"""简体中文"""
|
||||
|
||||
zh_Hant: 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:
|
||||
"""转换为字典"""
|
||||
dic = {}
|
||||
@@ -29,8 +41,16 @@ class I18nString(pydantic.BaseModel):
|
||||
dic['en_US'] = self.en_US
|
||||
if self.zh_Hans is not None:
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ class Bot(Base):
|
||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||
use_pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
use_pipeline_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
pipeline_routing_rules = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||
updated_at = sqlalchemy.Column(
|
||||
sqlalchemy.DateTime,
|
||||
|
||||
36
src/langbot/pkg/entity/persistence/human_takeover.py
Normal file
36
src/langbot/pkg/entity/persistence/human_takeover.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class HumanTakeoverSession(Base):
|
||||
"""Human takeover session records.
|
||||
|
||||
Tracks which conversation sessions are currently under human operator control,
|
||||
bypassing the normal AI pipeline processing.
|
||||
"""
|
||||
|
||||
__tablename__ = 'human_takeover_sessions'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||
"""Corresponds to monitoring_sessions.session_id, format: 'person_{id}' or 'group_{id}'"""
|
||||
|
||||
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
"""UUID of the bot whose session is being taken over"""
|
||||
|
||||
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='active', index=True)
|
||||
"""Takeover status: 'active' or 'released'"""
|
||||
|
||||
taken_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Email/username of the admin who took over the session"""
|
||||
|
||||
taken_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
||||
"""Timestamp when the takeover started"""
|
||||
|
||||
released_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
"""Timestamp when the takeover was released (null if still active)"""
|
||||
|
||||
platform = 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)
|
||||
@@ -106,3 +106,26 @@ class MonitoringEmbeddingCall(Base):
|
||||
session_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
|
||||
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import sqlalchemy
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class(25)
|
||||
class DBMigrateBotPipelineRoutingRules(migration.DBMigration):
|
||||
"""Add pipeline_routing_rules column to bots table"""
|
||||
|
||||
async def upgrade(self):
|
||||
sql_text = sqlalchemy.text("ALTER TABLE bots ADD COLUMN pipeline_routing_rules JSON NOT NULL DEFAULT '[]'")
|
||||
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||
|
||||
async def downgrade(self):
|
||||
sql_text = sqlalchemy.text('ALTER TABLE bots DROP COLUMN pipeline_routing_rules')
|
||||
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||
@@ -0,0 +1,36 @@
|
||||
import sqlalchemy
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class(26)
|
||||
class DBMigrateHumanTakeoverSessions(migration.DBMigration):
|
||||
"""Create human_takeover_sessions table for human operator takeover support"""
|
||||
|
||||
async def upgrade(self):
|
||||
sql_text = sqlalchemy.text("""
|
||||
CREATE TABLE IF NOT EXISTS human_takeover_sessions (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
session_id VARCHAR(255) NOT NULL UNIQUE,
|
||||
bot_uuid VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||
taken_by VARCHAR(255),
|
||||
taken_at DATETIME NOT NULL,
|
||||
released_at DATETIME,
|
||||
platform VARCHAR(255),
|
||||
user_id VARCHAR(255),
|
||||
user_name VARCHAR(255)
|
||||
)
|
||||
""")
|
||||
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||
|
||||
# Create indexes
|
||||
for idx_sql in [
|
||||
'CREATE INDEX IF NOT EXISTS idx_hts_session_id ON human_takeover_sessions (session_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_hts_bot_uuid ON human_takeover_sessions (bot_uuid)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_hts_status ON human_takeover_sessions (status)',
|
||||
]:
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text(idx_sql))
|
||||
|
||||
async def downgrade(self):
|
||||
sql_text = sqlalchemy.text('DROP TABLE IF EXISTS human_takeover_sessions')
|
||||
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||
@@ -37,6 +37,7 @@ class PendingMessage:
|
||||
message_chain: platform_message.MessageChain
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
pipeline_uuid: typing.Optional[str]
|
||||
routed_by_rule: bool = False
|
||||
timestamp: float = field(default_factory=time.time)
|
||||
|
||||
|
||||
@@ -125,6 +126,7 @@ class MessageAggregator:
|
||||
message_chain: platform_message.MessageChain,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
pipeline_uuid: typing.Optional[str] = None,
|
||||
routed_by_rule: bool = False,
|
||||
) -> None:
|
||||
"""Add a message to the aggregation buffer
|
||||
|
||||
@@ -145,6 +147,7 @@ class MessageAggregator:
|
||||
message_chain=message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -159,6 +162,7 @@ class MessageAggregator:
|
||||
message_chain=message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
)
|
||||
|
||||
force_flush = False
|
||||
@@ -217,6 +221,7 @@ class MessageAggregator:
|
||||
message_chain=msg.message_chain,
|
||||
adapter=msg.adapter,
|
||||
pipeline_uuid=msg.pipeline_uuid,
|
||||
routed_by_rule=msg.routed_by_rule,
|
||||
)
|
||||
return
|
||||
|
||||
@@ -231,6 +236,7 @@ class MessageAggregator:
|
||||
message_chain=merged_msg.message_chain,
|
||||
adapter=merged_msg.adapter,
|
||||
pipeline_uuid=merged_msg.pipeline_uuid,
|
||||
routed_by_rule=merged_msg.routed_by_rule,
|
||||
)
|
||||
|
||||
def _merge_messages(self, messages: list[PendingMessage]) -> PendingMessage:
|
||||
|
||||
@@ -63,6 +63,14 @@ class Controller:
|
||||
pipeline = await self.ap.pipeline_mgr.get_pipeline_by_uuid(pipeline_uuid)
|
||||
if pipeline:
|
||||
await pipeline.run(selected_query)
|
||||
else:
|
||||
self.ap.logger.warning(
|
||||
f'Pipeline {pipeline_uuid} not found for query {selected_query.query_id}, query dropped'
|
||||
)
|
||||
else:
|
||||
self.ap.logger.warning(
|
||||
f'No pipeline_uuid for query {selected_query.query_id}, query dropped'
|
||||
)
|
||||
|
||||
async with self.ap.query_pool:
|
||||
(await self.ap.sess_mgr.get_session(selected_query))._semaphore.release()
|
||||
|
||||
@@ -323,6 +323,9 @@ class RuntimePipeline:
|
||||
event_ctx = await self.ap.plugin_connector.emit_event(event_obj, bound_plugins)
|
||||
|
||||
if event_ctx.is_prevented_default():
|
||||
self.ap.logger.debug(
|
||||
f'MessageReceived event prevented default for query {query.query_id}, pipeline={pipeline_name}'
|
||||
)
|
||||
return
|
||||
|
||||
self.ap.logger.debug(f'Processing query {query.query_id}')
|
||||
|
||||
@@ -41,6 +41,7 @@ class QueryPool:
|
||||
message_chain: platform_message.MessageChain,
|
||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||
pipeline_uuid: typing.Optional[str] = None,
|
||||
routed_by_rule: bool = False,
|
||||
) -> pipeline_query.Query:
|
||||
async with self.condition:
|
||||
query_id = self.query_id_counter
|
||||
@@ -52,7 +53,7 @@ class QueryPool:
|
||||
sender_id=sender_id,
|
||||
message_event=message_event,
|
||||
message_chain=message_chain,
|
||||
variables={},
|
||||
variables={'_routed_by_rule': routed_by_rule},
|
||||
resp_messages=[],
|
||||
resp_message_chain=[],
|
||||
adapter=adapter,
|
||||
|
||||
@@ -61,6 +61,9 @@ class ChatMessageHandler(handler.MessageHandler):
|
||||
|
||||
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
else:
|
||||
self.ap.logger.debug(
|
||||
f'NormalMessageReceived event prevented default for query {query.query_id} without reply'
|
||||
)
|
||||
yield entities.StageProcessResult(result_type=entities.ResultType.INTERRUPT, new_query=query)
|
||||
else:
|
||||
if event_ctx.event.user_message_alter is not None:
|
||||
|
||||
@@ -37,6 +37,10 @@ class GroupRespondRuleCheckStage(stage.PipelineStage):
|
||||
if query.launcher_type.value != 'group': # 只处理群消息
|
||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
|
||||
# 通过路由规则明确指定的流水线,跳过群响应规则检查
|
||||
if query.variables and query.variables.get('_routed_by_rule', False):
|
||||
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
|
||||
|
||||
rules = query.pipeline_config['trigger']['group-respond-rules']
|
||||
|
||||
use_rule = rules
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import traceback
|
||||
import sqlalchemy
|
||||
|
||||
@@ -9,6 +11,7 @@ from ..core import app, entities as core_entities, taskmgr
|
||||
from ..discover import engine
|
||||
|
||||
from ..entity.persistence import bot as persistence_bot
|
||||
from ..entity.persistence import pipeline as persistence_pipeline
|
||||
|
||||
from ..entity.errors import platform as platform_errors
|
||||
|
||||
@@ -51,6 +54,148 @@ class RuntimeBot:
|
||||
self.task_context = taskmgr.TaskContext()
|
||||
self.logger = logger
|
||||
|
||||
@staticmethod
|
||||
def _match_operator(actual: str, operator: str, expected: str) -> bool:
|
||||
"""Evaluate a single operator condition."""
|
||||
if operator == 'eq':
|
||||
return actual == expected
|
||||
elif operator == 'neq':
|
||||
return actual != expected
|
||||
elif operator == 'contains':
|
||||
return expected in actual
|
||||
elif operator == 'not_contains':
|
||||
return expected not in actual
|
||||
elif operator == 'starts_with':
|
||||
return actual.startswith(expected)
|
||||
elif operator == 'regex':
|
||||
try:
|
||||
return bool(re.search(expected, actual))
|
||||
except re.error:
|
||||
return False
|
||||
return False
|
||||
|
||||
PIPELINE_DISCARD = '__discard__'
|
||||
PIPELINE_DISCARD_DISPLAY_NAME = 'Discarded'
|
||||
|
||||
def resolve_pipeline_uuid(
|
||||
self,
|
||||
launcher_type: str,
|
||||
launcher_id: str,
|
||||
message_text: str,
|
||||
message_element_types: list[str] | None = None,
|
||||
) -> tuple[str | None, bool]:
|
||||
"""Resolve pipeline UUID based on routing rules.
|
||||
|
||||
Rules are evaluated in order; first match wins.
|
||||
Falls back to use_pipeline_uuid if no rule matches.
|
||||
|
||||
Rule types:
|
||||
- launcher_type: session type ("person" / "group")
|
||||
- launcher_id: session / group id
|
||||
- message_content: message text content
|
||||
- message_has_element: message contains element of given type
|
||||
(Image, Voice, File, Forward, Face, At, AtAll, Quote)
|
||||
Operators: eq (has), neq (doesn't have)
|
||||
|
||||
Operators: eq, neq, contains, not_contains, starts_with, regex
|
||||
|
||||
When pipeline_uuid is ``__discard__``, the message should be
|
||||
silently dropped by the caller.
|
||||
|
||||
Returns:
|
||||
tuple: (pipeline_uuid, routed_by_rule) - routed_by_rule is True
|
||||
when a routing rule matched, False when falling back to default.
|
||||
"""
|
||||
rules = self.bot_entity.pipeline_routing_rules or []
|
||||
element_type_set = set(message_element_types or [])
|
||||
|
||||
for rule in rules:
|
||||
rule_type = rule.get('type')
|
||||
operator = rule.get('operator', 'eq')
|
||||
rule_value = rule.get('value', '')
|
||||
target_uuid = rule.get('pipeline_uuid')
|
||||
if not rule_type or not target_uuid:
|
||||
continue
|
||||
|
||||
if rule_type == 'launcher_type':
|
||||
if self._match_operator(launcher_type, operator, rule_value):
|
||||
return target_uuid, True
|
||||
elif rule_type == 'launcher_id':
|
||||
if self._match_operator(str(launcher_id), operator, str(rule_value)):
|
||||
return target_uuid, True
|
||||
elif rule_type == 'message_content':
|
||||
if self._match_operator(message_text, operator, rule_value):
|
||||
return target_uuid, True
|
||||
elif rule_type == 'message_has_element':
|
||||
has_element = rule_value in element_type_set
|
||||
if operator == 'eq' and has_element:
|
||||
return target_uuid, True
|
||||
elif operator == 'neq' and not has_element:
|
||||
return target_uuid, True
|
||||
|
||||
return self.bot_entity.use_pipeline_uuid, False
|
||||
|
||||
async def _record_discarded_message(
|
||||
self,
|
||||
launcher_type: provider_session.LauncherTypes,
|
||||
launcher_id: str | int,
|
||||
sender_id: str | int,
|
||||
message_event: platform_events.MessageEvent,
|
||||
message_chain: platform_message.MessageChain,
|
||||
) -> None:
|
||||
"""Record a discarded message in the monitoring system."""
|
||||
try:
|
||||
if hasattr(message_chain, 'model_dump'):
|
||||
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
|
||||
else:
|
||||
message_content = str(message_chain)
|
||||
|
||||
sender_name = None
|
||||
if hasattr(message_event, 'sender'):
|
||||
if hasattr(message_event.sender, 'nickname'):
|
||||
sender_name = message_event.sender.nickname
|
||||
elif hasattr(message_event.sender, 'member_name'):
|
||||
sender_name = message_event.sender.member_name
|
||||
|
||||
# Use the same session_id format as monitoring_helper.py
|
||||
session_id = f'{launcher_type}_{launcher_id}'
|
||||
platform = launcher_type.value if hasattr(launcher_type, 'value') else str(launcher_type)
|
||||
|
||||
await self.ap.monitoring_service.record_message(
|
||||
bot_id=self.bot_entity.uuid,
|
||||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||
pipeline_id=self.PIPELINE_DISCARD,
|
||||
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||
message_content=message_content,
|
||||
session_id=session_id,
|
||||
status='discarded',
|
||||
level='info',
|
||||
platform=platform,
|
||||
user_id=str(sender_id),
|
||||
user_name=sender_name,
|
||||
)
|
||||
|
||||
# Ensure the session exists so the message appears in the session monitor.
|
||||
# Don't overwrite pipeline info — a session may have messages from
|
||||
# multiple pipelines; discarding shouldn't change the displayed pipeline.
|
||||
session_updated = await self.ap.monitoring_service.update_session_activity(
|
||||
session_id,
|
||||
)
|
||||
if not session_updated:
|
||||
# No session yet (first message for this launcher was discarded).
|
||||
await self.ap.monitoring_service.record_session_start(
|
||||
session_id=session_id,
|
||||
bot_id=self.bot_entity.uuid,
|
||||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||
pipeline_id=self.PIPELINE_DISCARD,
|
||||
pipeline_name=self.PIPELINE_DISCARD_DISPLAY_NAME,
|
||||
platform=platform,
|
||||
user_id=str(sender_id),
|
||||
user_name=sender_name,
|
||||
)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to record discarded message: {e}')
|
||||
|
||||
async def initialize(self):
|
||||
async def on_friend_message(
|
||||
event: platform_events.FriendMessage,
|
||||
@@ -75,6 +220,47 @@ class RuntimeBot:
|
||||
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
if not skip_pipeline:
|
||||
# Check if session is under human takeover
|
||||
person_session_id = f'person_{event.sender.id}'
|
||||
if (
|
||||
hasattr(self.ap, 'human_takeover_service')
|
||||
and self.ap.human_takeover_service
|
||||
and self.ap.human_takeover_service.is_taken_over(person_session_id)
|
||||
):
|
||||
# Session is taken over: record message to monitoring then stop
|
||||
await self.logger.info(
|
||||
f'Person message intercepted by human takeover for session {person_session_id}'
|
||||
)
|
||||
try:
|
||||
if hasattr(event.message_chain, 'model_dump'):
|
||||
msg_content = json.dumps(event.message_chain.model_dump(), ensure_ascii=False)
|
||||
else:
|
||||
msg_content = str(event.message_chain)
|
||||
|
||||
sender_name = None
|
||||
if hasattr(event, 'sender') and hasattr(event.sender, 'nickname'):
|
||||
sender_name = event.sender.nickname
|
||||
|
||||
await self.ap.monitoring_service.record_message(
|
||||
bot_id=self.bot_entity.uuid,
|
||||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||
pipeline_id='__human_takeover__',
|
||||
pipeline_name='Human Takeover',
|
||||
message_content=msg_content,
|
||||
session_id=person_session_id,
|
||||
status='success',
|
||||
level='info',
|
||||
platform=adapter.__class__.__name__,
|
||||
user_id=str(event.sender.id),
|
||||
user_name=sender_name,
|
||||
role='user',
|
||||
)
|
||||
|
||||
await self.ap.monitoring_service.update_session_activity(person_session_id)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to record takeover message: {e}')
|
||||
return
|
||||
|
||||
launcher_id = event.sender.id
|
||||
|
||||
if hasattr(adapter, 'get_launcher_id'):
|
||||
@@ -82,6 +268,23 @@ class RuntimeBot:
|
||||
if custom_launcher_id:
|
||||
launcher_id = custom_launcher_id
|
||||
|
||||
message_text = str(event.message_chain)
|
||||
element_types = [comp.type for comp in event.message_chain]
|
||||
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||
'person', launcher_id, message_text, element_types
|
||||
)
|
||||
|
||||
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||
await self.logger.info('Person message discarded by routing rule')
|
||||
await self._record_discarded_message(
|
||||
provider_session.LauncherTypes.PERSON,
|
||||
launcher_id,
|
||||
event.sender.id,
|
||||
event,
|
||||
event.message_chain,
|
||||
)
|
||||
return
|
||||
|
||||
await self.ap.msg_aggregator.add_message(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
@@ -90,7 +293,8 @@ class RuntimeBot:
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
)
|
||||
else:
|
||||
await self.logger.info('Pipeline skipped for person message due to webhook response')
|
||||
@@ -118,6 +322,50 @@ class RuntimeBot:
|
||||
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
if not skip_pipeline:
|
||||
# Check if session is under human takeover
|
||||
group_session_id = f'group_{event.group.id}'
|
||||
if (
|
||||
hasattr(self.ap, 'human_takeover_service')
|
||||
and self.ap.human_takeover_service
|
||||
and self.ap.human_takeover_service.is_taken_over(group_session_id)
|
||||
):
|
||||
# Session is taken over: record message to monitoring then stop
|
||||
await self.logger.info(
|
||||
f'Group message intercepted by human takeover for session {group_session_id}'
|
||||
)
|
||||
try:
|
||||
if hasattr(event.message_chain, 'model_dump'):
|
||||
msg_content = json.dumps(event.message_chain.model_dump(), ensure_ascii=False)
|
||||
else:
|
||||
msg_content = str(event.message_chain)
|
||||
|
||||
sender_name = None
|
||||
if hasattr(event, 'sender'):
|
||||
if hasattr(event.sender, 'member_name'):
|
||||
sender_name = event.sender.member_name
|
||||
elif hasattr(event.sender, 'nickname'):
|
||||
sender_name = event.sender.nickname
|
||||
|
||||
await self.ap.monitoring_service.record_message(
|
||||
bot_id=self.bot_entity.uuid,
|
||||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||
pipeline_id='__human_takeover__',
|
||||
pipeline_name='Human Takeover',
|
||||
message_content=msg_content,
|
||||
session_id=group_session_id,
|
||||
status='success',
|
||||
level='info',
|
||||
platform=adapter.__class__.__name__,
|
||||
user_id=str(event.sender.id),
|
||||
user_name=sender_name,
|
||||
role='user',
|
||||
)
|
||||
|
||||
await self.ap.monitoring_service.update_session_activity(group_session_id)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to record takeover message: {e}')
|
||||
return
|
||||
|
||||
launcher_id = event.group.id
|
||||
|
||||
if hasattr(adapter, 'get_launcher_id'):
|
||||
@@ -125,6 +373,23 @@ class RuntimeBot:
|
||||
if custom_launcher_id:
|
||||
launcher_id = custom_launcher_id
|
||||
|
||||
message_text = str(event.message_chain)
|
||||
element_types = [comp.type for comp in event.message_chain]
|
||||
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
||||
'group', launcher_id, message_text, element_types
|
||||
)
|
||||
|
||||
if pipeline_uuid == self.PIPELINE_DISCARD:
|
||||
await self.logger.info('Group message discarded by routing rule')
|
||||
await self._record_discarded_message(
|
||||
provider_session.LauncherTypes.GROUP,
|
||||
launcher_id,
|
||||
event.sender.id,
|
||||
event,
|
||||
event.message_chain,
|
||||
)
|
||||
return
|
||||
|
||||
await self.ap.msg_aggregator.add_message(
|
||||
bot_uuid=self.bot_entity.uuid,
|
||||
launcher_type=provider_session.LauncherTypes.GROUP,
|
||||
@@ -133,7 +398,8 @@ class RuntimeBot:
|
||||
message_event=event,
|
||||
message_chain=event.message_chain,
|
||||
adapter=adapter,
|
||||
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
routed_by_rule=routed_by_rule,
|
||||
)
|
||||
else:
|
||||
await self.logger.info('Pipeline skipped for group message due to webhook response')
|
||||
@@ -141,6 +407,50 @@ class RuntimeBot:
|
||||
self.adapter.register_listener(platform_events.FriendMessage, on_friend_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 exception_wrapper():
|
||||
try:
|
||||
@@ -196,12 +506,20 @@ class PlatformManager:
|
||||
# delete all bot log images
|
||||
await self.ap.storage_mgr.storage_provider.delete_dir_recursive('bot_log_images')
|
||||
|
||||
disabled_adapters = self.ap.instance_config.data.get('system', {}).get('disabled_adapters', []) or []
|
||||
|
||||
self.adapter_components = self.ap.discover.get_components_by_kind('MessagePlatformAdapter')
|
||||
adapter_dict: dict[str, type[abstract_platform_adapter.AbstractMessagePlatformAdapter]] = {}
|
||||
for component in self.adapter_components:
|
||||
if component.metadata.name in disabled_adapters:
|
||||
continue
|
||||
adapter_dict[component.metadata.name] = component.get_python_component_class()
|
||||
self.adapter_dict = adapter_dict
|
||||
|
||||
# Filter out disabled adapters from components list (for API responses)
|
||||
if disabled_adapters:
|
||||
self.adapter_components = [c for c in self.adapter_components if c.metadata.name not in disabled_adapters]
|
||||
|
||||
# initialize websocket adapter
|
||||
websocket_adapter_class = self.adapter_dict['websocket']
|
||||
websocket_logger = EventLogger(name='websocket-adapter', ap=self.ap)
|
||||
|
||||
@@ -5,19 +5,29 @@ metadata:
|
||||
label:
|
||||
en_US: OneBot v11
|
||||
zh_Hans: OneBot v11
|
||||
zh_Hant: OneBot v11
|
||||
description:
|
||||
en_US: OneBot v11 Adapter
|
||||
zh_Hans: OneBot v11 适配器,请查看文档了解使用方式
|
||||
en_US: OneBot v11 Adapter, used for QQ bots
|
||||
zh_Hans: OneBot v11 适配器,用于接入 QQ 机器人协议端,请查看文档了解使用方式
|
||||
zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式
|
||||
icon: onebot.png
|
||||
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:
|
||||
- name: host
|
||||
label:
|
||||
en_US: Host
|
||||
zh_Hans: 主机
|
||||
zh_Hant: 主機
|
||||
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
|
||||
zh_Hans: OneBot v11 监听的反向 WS 主机,除非你知道自己在做什么,否则请写 0.0.0.0
|
||||
zh_Hant: OneBot v11 監聽的反向 WS 主機,除非你知道自己在做什麼,否則請填 0.0.0.0
|
||||
type: string
|
||||
required: true
|
||||
default: 0.0.0.0
|
||||
@@ -25,9 +35,11 @@ spec:
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 端口
|
||||
zh_Hant: 連接埠
|
||||
description:
|
||||
en_US: Port
|
||||
zh_Hans: 监听的端口
|
||||
zh_Hant: 監聽的連接埠
|
||||
type: integer
|
||||
required: true
|
||||
default: 2280
|
||||
@@ -35,9 +47,11 @@ spec:
|
||||
label:
|
||||
en_US: Access Token
|
||||
zh_Hans: 访问令牌
|
||||
zh_Hant: 存取令牌
|
||||
description:
|
||||
en_US: Custom connection token for the protocol endpoint. If the protocol endpoint is not set, don't fill it
|
||||
zh_Hans: 自定义的与协议端的连接令牌,若协议端未设置,则不填
|
||||
zh_Hant: 自訂的與協定端的連線令牌,若協定端未設定,則不填
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
@@ -5,16 +5,25 @@ metadata:
|
||||
label:
|
||||
en_US: DingTalk
|
||||
zh_Hans: 钉钉
|
||||
zh_Hant: 釘釘
|
||||
description:
|
||||
en_US: DingTalk Adapter
|
||||
zh_Hans: 钉钉适配器,请查看文档了解使用方式
|
||||
zh_Hant: 釘釘適配器,請查看文件了解使用方式
|
||||
icon: dingtalk.svg
|
||||
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:
|
||||
- name: client_id
|
||||
label:
|
||||
en_US: Client ID
|
||||
zh_Hans: 客户端ID
|
||||
zh_Hant: 用戶端ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +31,7 @@ spec:
|
||||
label:
|
||||
en_US: Client Secret
|
||||
zh_Hans: 客户端密钥
|
||||
zh_Hant: 用戶端密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,6 +39,7 @@ spec:
|
||||
label:
|
||||
en_US: Robot Code
|
||||
zh_Hans: 机器人代码
|
||||
zh_Hant: 機器人代碼
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -36,6 +47,7 @@ spec:
|
||||
label:
|
||||
en_US: Robot Name
|
||||
zh_Hans: 机器人名称
|
||||
zh_Hant: 機器人名稱
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -43,6 +55,7 @@ spec:
|
||||
label:
|
||||
en_US: Markdown Card
|
||||
zh_Hans: 是否使用 Markdown 卡片
|
||||
zh_Hant: 是否使用 Markdown 卡片
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
@@ -50,9 +63,11 @@ spec:
|
||||
label:
|
||||
en_US: Enable Stream Reply Mode
|
||||
zh_Hans: 启用钉钉卡片流式回复模式
|
||||
zh_Hant: 啟用釘釘卡片串流回覆模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||
zh_Hans: 如果启用,将使用钉钉卡片流式方式来回复内容
|
||||
zh_Hant: 如果啟用,將使用釘釘卡片串流方式來回覆內容
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
@@ -60,6 +75,7 @@ spec:
|
||||
label:
|
||||
en_US: Card Auto Layout
|
||||
zh_Hans: 卡片宽屏自动布局
|
||||
zh_Hant: 卡片寬螢幕自動佈局
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
@@ -67,6 +83,7 @@ spec:
|
||||
label:
|
||||
en_US: card template id
|
||||
zh_Hans: 卡片模板ID
|
||||
zh_Hant: 卡片範本ID
|
||||
type: string
|
||||
required: true
|
||||
default: "填写你的卡片template_id"
|
||||
|
||||
@@ -5,16 +5,38 @@ metadata:
|
||||
label:
|
||||
en_US: Discord
|
||||
zh_Hans: Discord
|
||||
zh_Hant: Discord
|
||||
ja_JP: Discord
|
||||
th_TH: Discord
|
||||
vi_VN: Discord
|
||||
es_ES: Discord
|
||||
description:
|
||||
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
|
||||
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:
|
||||
- name: client_id
|
||||
label:
|
||||
en_US: Client 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
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +44,11 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
ja_JP: トークン
|
||||
th_TH: โทเค็น
|
||||
vi_VN: Mã thông báo
|
||||
es_ES: Token
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -5,16 +5,25 @@ metadata:
|
||||
label:
|
||||
en_US: KOOK
|
||||
zh_Hans: KOOK
|
||||
zh_Hant: KOOK
|
||||
description:
|
||||
en_US: KOOK Adapter (formerly KaiHeiLa)
|
||||
zh_Hans: KOOK 适配器(原开黑啦),支持频道消息和私聊消息
|
||||
zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息
|
||||
icon: kook.png
|
||||
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:
|
||||
- name: token
|
||||
label:
|
||||
en_US: Bot Token
|
||||
zh_Hans: 机器人令牌
|
||||
zh_Hant: 機器人令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -797,8 +797,65 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
def sync_on_message(event: lark_oapi.im.v1.P2ImMessageReceiveV1):
|
||||
asyncio.create_task(on_message(event))
|
||||
|
||||
def sync_on_card_action(event):
|
||||
try:
|
||||
action_value_obj = getattr(getattr(event.event, 'action', None), 'value', {})
|
||||
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
||||
|
||||
if action_value == '有帮助':
|
||||
feedback_type = 1
|
||||
elif action_value == '无帮助':
|
||||
feedback_type = 2
|
||||
else:
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||
|
||||
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '操作成功'}})
|
||||
|
||||
operator = getattr(event.event, 'operator', None)
|
||||
context = getattr(event.event, 'context', None)
|
||||
|
||||
user_id = getattr(operator, 'open_id', None) or getattr(operator, 'user_id', None)
|
||||
open_chat_id = getattr(context, 'open_chat_id', None)
|
||||
open_message_id = getattr(context, 'open_message_id', None)
|
||||
|
||||
if open_chat_id:
|
||||
session_id = f'group_{open_chat_id}'
|
||||
elif user_id:
|
||||
session_id = f'person_{user_id}'
|
||||
else:
|
||||
session_id = None
|
||||
|
||||
feedback_event = platform_events.FeedbackEvent(
|
||||
feedback_id=getattr(event.header, 'event_id', str(uuid.uuid4())),
|
||||
feedback_type=feedback_type,
|
||||
feedback_content=action_value,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
message_id=open_message_id,
|
||||
source_platform_object=event,
|
||||
)
|
||||
|
||||
if platform_events.FeedbackEvent in self.listeners:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
asyncio.create_task(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||
else:
|
||||
loop.run_until_complete(self.listeners[platform_events.FeedbackEvent](feedback_event, self))
|
||||
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||
|
||||
return P2CardActionTriggerResponse({'toast': {'type': 'success', 'content': '感谢您的反馈'}})
|
||||
except Exception:
|
||||
asyncio.create_task(self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}'))
|
||||
from lark_oapi.event.callback.model.p2_card_action_trigger import P2CardActionTriggerResponse
|
||||
|
||||
return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}})
|
||||
|
||||
event_handler = (
|
||||
lark_oapi.EventDispatcherHandler.builder('', '').register_p2_im_message_receive_v1(sync_on_message).build()
|
||||
lark_oapi.EventDispatcherHandler.builder('', '')
|
||||
.register_p2_im_message_receive_v1(sync_on_message)
|
||||
.register_p2_card_action_trigger(sync_on_card_action)
|
||||
.build()
|
||||
)
|
||||
|
||||
bot_account_id = config['bot_name']
|
||||
@@ -1088,6 +1145,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
'size': 'medium',
|
||||
'icon': {'tag': 'standard_icon', 'token': 'thumbsup_outlined'},
|
||||
'hover_tips': {'tag': 'plain_text', 'content': '有帮助'},
|
||||
'behaviors': [{'type': 'callback', 'value': {'feedback': '有帮助'}}],
|
||||
'margin': '0px 0px 0px 0px',
|
||||
}
|
||||
],
|
||||
@@ -1111,6 +1169,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
'size': 'medium',
|
||||
'icon': {'tag': 'standard_icon', 'token': 'thumbdown_outlined'},
|
||||
'hover_tips': {'tag': 'plain_text', 'content': '无帮助'},
|
||||
'behaviors': [{'type': 'callback', 'value': {'feedback': '无帮助'}}],
|
||||
'margin': '0px 0px 0px 0px',
|
||||
}
|
||||
],
|
||||
@@ -1472,6 +1531,52 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
if event.__class__ in self.listeners:
|
||||
await self.listeners[event.__class__](event, self)
|
||||
elif 'card.action.trigger' == type:
|
||||
try:
|
||||
event_data = data.get('event', {})
|
||||
operator = event_data.get('operator', {})
|
||||
action = event_data.get('action', {})
|
||||
context_data = event_data.get('context', {})
|
||||
|
||||
action_value_obj = action.get('value', {})
|
||||
action_value = action_value_obj.get('feedback', '') if isinstance(action_value_obj, dict) else ''
|
||||
|
||||
if action_value == '有帮助':
|
||||
feedback_type = 1
|
||||
elif action_value == '无帮助':
|
||||
feedback_type = 2
|
||||
else:
|
||||
return {'toast': {'type': 'success', 'content': '操作成功'}}
|
||||
|
||||
user_id = operator.get('open_id') or operator.get('user_id')
|
||||
open_chat_id = context_data.get('open_chat_id')
|
||||
open_message_id = context_data.get('open_message_id')
|
||||
|
||||
if open_chat_id:
|
||||
session_id = f'group_{open_chat_id}'
|
||||
elif user_id:
|
||||
session_id = f'person_{user_id}'
|
||||
else:
|
||||
session_id = None
|
||||
|
||||
feedback_event = platform_events.FeedbackEvent(
|
||||
feedback_id=data.get('header', {}).get('event_id', str(uuid.uuid4())),
|
||||
feedback_type=feedback_type,
|
||||
feedback_content=action_value,
|
||||
user_id=user_id,
|
||||
session_id=session_id,
|
||||
message_id=open_message_id,
|
||||
source_platform_object=data,
|
||||
)
|
||||
|
||||
if platform_events.FeedbackEvent in self.listeners:
|
||||
await self.listeners[platform_events.FeedbackEvent](feedback_event, self)
|
||||
|
||||
return {'toast': {'type': 'success', 'content': '感谢您的反馈'}}
|
||||
except Exception:
|
||||
await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')
|
||||
return {'toast': {'type': 'error', 'content': '反馈处理失败'}}
|
||||
|
||||
elif 'im.chat.member.bot.added_v1' == type:
|
||||
try:
|
||||
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
|
||||
|
||||
@@ -5,16 +5,30 @@ metadata:
|
||||
label:
|
||||
en_US: Lark
|
||||
zh_Hans: 飞书
|
||||
zh_Hant: 飛書
|
||||
ja_JP: Lark
|
||||
description:
|
||||
en_US: Lark Adapter
|
||||
zh_Hans: 飞书适配器,请查看文档了解使用方式
|
||||
en_US: Lark Adapter, supports both long connection and Webhook modes. Please refer to the documentation for usage details.
|
||||
zh_Hans: 飞书适配器,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
|
||||
zh_Hant: 飛書適配器,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
|
||||
ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。
|
||||
icon: lark.svg
|
||||
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:
|
||||
- name: app_id
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用ID
|
||||
zh_Hant: 應用ID
|
||||
ja_JP: アプリ ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +36,8 @@ spec:
|
||||
label:
|
||||
en_US: App Secret
|
||||
zh_Hans: 应用密钥
|
||||
zh_Hant: 應用密鑰
|
||||
ja_JP: アプリシークレット
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,9 +45,13 @@ spec:
|
||||
label:
|
||||
en_US: Bot Name
|
||||
zh_Hans: 机器人名称
|
||||
zh_Hant: 機器人名稱
|
||||
ja_JP: ボット名
|
||||
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
|
||||
zh_Hans: 必须与飞书机器人名称一致,否则机器人将无法在群内正常接收消息
|
||||
zh_Hant: 必須與飛書機器人名稱一致,否則機器人將無法在群組內正常接收訊息
|
||||
ja_JP: Lark のボット名と一致する必要があります。一致しない場合、グループ内でメッセージを受信できません
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -39,29 +59,63 @@ spec:
|
||||
label:
|
||||
en_US: Enable Webhook Mode
|
||||
zh_Hans: 启用Webhook模式
|
||||
zh_Hant: 啟用 Webhook 模式
|
||||
ja_JP: 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 長連線模式
|
||||
ja_JP: 有効にすると、ボットは Webhook モードでメッセージを受信します。無効の場合は WS 長期接続モードを使用します
|
||||
type: boolean
|
||||
required: true
|
||||
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
|
||||
label:
|
||||
en_US: Encrypt Key
|
||||
zh_Hans: 加密密钥
|
||||
zh_Hant: 加密密鑰
|
||||
ja_JP: 暗号化キー
|
||||
description:
|
||||
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
||||
zh_Hans: 仅在启用 Webhook 模式时有效,请填写加密密钥
|
||||
zh_Hant: 僅在啟用 Webhook 模式時有效,請填寫加密密鑰
|
||||
ja_JP: Webhook モードが有効な場合にのみ有効です。暗号化キーを入力してください
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
show_if:
|
||||
field: enable-webhook
|
||||
operator: eq
|
||||
value: true
|
||||
- name: enable-stream-reply
|
||||
label:
|
||||
en_US: Enable Stream Reply Mode
|
||||
zh_Hans: 启用飞书流式回复模式
|
||||
zh_Hant: 啟用飛書串流回覆模式
|
||||
ja_JP: ストリーミング返信モードを有効化
|
||||
description:
|
||||
en_US: If enabled, the bot will use the stream of lark reply mode
|
||||
zh_Hans: 如果启用,将使用飞书流式方式来回复内容
|
||||
zh_Hant: 如果啟用,將使用飛書串流方式來回覆內容
|
||||
ja_JP: 有効にすると、ボットはストリーミングモードでメッセージに返信します
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
@@ -69,28 +123,40 @@ spec:
|
||||
label:
|
||||
en_US: App Type
|
||||
zh_Hans: 应用类型
|
||||
zh_Hant: 應用類型
|
||||
ja_JP: アプリタイプ
|
||||
description:
|
||||
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
||||
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||
zh_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||
ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください
|
||||
type: select
|
||||
options:
|
||||
- name: self
|
||||
label:
|
||||
en_US: Self-built Application
|
||||
zh_Hans: 自建应用
|
||||
zh_Hant: 自建應用
|
||||
ja_JP: カスタムアプリ
|
||||
- name: isv
|
||||
label:
|
||||
en_US: Store Application
|
||||
zh_Hans: 商店应用
|
||||
zh_Hant: 商店應用
|
||||
ja_JP: ストアアプリ
|
||||
required: false
|
||||
default: self
|
||||
- name: bot_added_welcome
|
||||
label:
|
||||
en_US: Bot Welcome Message
|
||||
zh_Hans: 机器人进群欢迎语
|
||||
zh_Hant: 機器人進群歡迎語
|
||||
ja_JP: ボット参加時のウェルカムメッセージ
|
||||
description:
|
||||
en_US: Welcome message when the bot is added to a group, supports Markdown format
|
||||
zh_Hans: 机器人进群欢迎语,支持 Markdown 格式
|
||||
zh_Hant: 機器人進群歡迎語,支援 Markdown 格式
|
||||
ja_JP: ボットがグループに追加された際のウェルカムメッセージ。Markdown 形式に対応しています
|
||||
type: text
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
@@ -5,20 +5,56 @@ metadata:
|
||||
label:
|
||||
en_US: LINE
|
||||
zh_Hans: LINE
|
||||
zh_Hant: LINE
|
||||
th_TH: LINE
|
||||
vi_VN: LINE
|
||||
es_ES: LINE
|
||||
description:
|
||||
en_US: LINE Adapter
|
||||
zh_Hans: LINE适配器,请查看文档了解使用方式
|
||||
ja_JP: LINEアダプター、ドキュメントを参照してください
|
||||
zh_Hant: LINE適配器,請查看文檔了解使用方式
|
||||
en_US: LINE Adapter, requires a public URL to receive LINE message pushes, please refer to the documentation for usage details
|
||||
zh_Hans: LINE适配器,需要公网地址以接收 LINE 消息推送,请查看文档了解使用方式
|
||||
zh_Hant: LINE 適配器,需要公網地址以接收 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
|
||||
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:
|
||||
- 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
|
||||
label:
|
||||
en_US: Channel access token
|
||||
zh_Hans: 频道访问令牌
|
||||
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
|
||||
required: true
|
||||
default: ""
|
||||
@@ -27,12 +63,18 @@ spec:
|
||||
en_US: Channel secret
|
||||
zh_Hans: 消息密钥
|
||||
ja_JP: チャンネルシークレット
|
||||
zh_Hant: 消息密钥
|
||||
zh_Hant: 訊息密鑰
|
||||
th_TH: รหัสลับช่อง
|
||||
vi_VN: Khóa bí mật kênh
|
||||
es_ES: Secreto del canal
|
||||
description:
|
||||
en_US: Only valid when webhook mode is enabled, please fill in the encrypt key
|
||||
zh_Hans: 请填写加密密钥
|
||||
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
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -5,23 +5,44 @@ metadata:
|
||||
label:
|
||||
en_US: Official Account
|
||||
zh_Hans: 微信公众号
|
||||
zh_Hant: 微信公眾號
|
||||
description:
|
||||
en_US: Official Account Adapter
|
||||
zh_Hans: 微信公众号适配器,请查看文档了解使用方式
|
||||
zh_Hans: 微信公众号适配器,需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||
zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||
icon: officialaccount.png
|
||||
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:
|
||||
- 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
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
type: string
|
||||
zh_Hant: 令牌
|
||||
required: true
|
||||
default: ""
|
||||
- name: EncodingAESKey
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥
|
||||
zh_Hant: 訊息加解密密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,6 +50,7 @@ spec:
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用ID
|
||||
zh_Hant: 應用ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -36,6 +58,7 @@ spec:
|
||||
label:
|
||||
en_US: App Secret
|
||||
zh_Hans: 应用密钥
|
||||
zh_Hant: 應用密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -43,6 +66,7 @@ spec:
|
||||
label:
|
||||
en_US: Mode
|
||||
zh_Hans: 接入模式
|
||||
zh_Hant: 接入模式
|
||||
type: string
|
||||
required: true
|
||||
default: "drop"
|
||||
@@ -50,6 +74,7 @@ spec:
|
||||
label:
|
||||
en_US: Loading Message
|
||||
zh_Hans: 加载消息
|
||||
zh_Hant: 載入訊息
|
||||
type: string
|
||||
required: true
|
||||
default: "AI正在思考中,请发送任意内容获取回复。"
|
||||
@@ -57,9 +82,11 @@ spec:
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础 URL
|
||||
zh_Hant: API 基礎 URL
|
||||
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.
|
||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问微信公众号 API,可根据文档修改此项
|
||||
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取微信公眾號 API,可根據文件修改此項
|
||||
type: string
|
||||
required: false
|
||||
default: "https://api.weixin.qq.com"
|
||||
|
||||
@@ -4,20 +4,31 @@ metadata:
|
||||
name: openclaw-weixin
|
||||
label:
|
||||
en_US: OpenClaw WeChat
|
||||
zh_Hans: OpenClaw 微信
|
||||
zh_Hans: 个人微信机器人
|
||||
zh_Hant: 個人微信機器人
|
||||
description:
|
||||
en_US: OpenClaw WeChat adapter, supports personal WeChat via QR code login
|
||||
zh_Hans: OpenClaw 微信适配器,通过扫码登录支持个人微信
|
||||
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"
|
||||
@@ -25,9 +36,11 @@ spec:
|
||||
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_Hans: 扫码登录授权后获取的 Bearer 令牌。请留空并保存,将在启动时输出二维码到日志,扫码后即可自动登录。
|
||||
zh_Hant: 掃碼登入授權後取得的 Bearer 令牌。請留空並儲存,將在啟動時輸出 QR Code 到日誌,掃碼後即可自動登入。
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
@@ -35,9 +48,11 @@ spec:
|
||||
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"
|
||||
@@ -45,9 +60,11 @@ spec:
|
||||
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
|
||||
|
||||
@@ -5,16 +5,37 @@ metadata:
|
||||
label:
|
||||
en_US: QQ Official API
|
||||
zh_Hans: QQ 官方 API
|
||||
zh_Hant: QQ 官方 API
|
||||
description:
|
||||
en_US: QQ Official API (Webhook)
|
||||
zh_Hans: QQ 官方 API (Webhook),请查看文档了解使用方式
|
||||
zh_Hans: QQ 官方 API (Webhook),需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||
zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||
icon: qqofficial.svg
|
||||
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:
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
zh_Hans: Webhook 回调地址
|
||||
zh_Hant: Webhook 回調地址
|
||||
description:
|
||||
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
|
||||
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
|
||||
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
- name: appid
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用ID
|
||||
zh_Hant: 應用ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +43,7 @@ spec:
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥
|
||||
zh_Hant: 密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,6 +51,7 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -5,36 +5,70 @@ metadata:
|
||||
label:
|
||||
en_US: Satori
|
||||
zh_Hans: Satori
|
||||
zh_Hant: Satori
|
||||
th_TH: Satori
|
||||
vi_VN: Satori
|
||||
es_ES: Satori
|
||||
description:
|
||||
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
|
||||
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:
|
||||
- name: platform
|
||||
label:
|
||||
en_US: Platform
|
||||
zh_Hans: 平台名称
|
||||
zh_Hant: 平台名稱
|
||||
th_TH: ชื่อแพลตฟอร์ม
|
||||
vi_VN: Tên nền tảng
|
||||
es_ES: Nombre de la plataforma
|
||||
type: string
|
||||
required: true
|
||||
default: "llonebot"
|
||||
description:
|
||||
en_US: The platform name (e.g., 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
|
||||
label:
|
||||
en_US: Host
|
||||
zh_Hans: 主机地址
|
||||
zh_Hant: 主機地址
|
||||
th_TH: ที่อยู่โฮสต์
|
||||
vi_VN: Địa chỉ máy chủ
|
||||
es_ES: Dirección del host
|
||||
type: string
|
||||
required: true
|
||||
default: "127.0.0.1"
|
||||
description:
|
||||
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_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
|
||||
label:
|
||||
en_US: Port
|
||||
zh_Hans: 监听端口
|
||||
zh_Hant: 監聽連接埠
|
||||
th_TH: พอร์ต
|
||||
vi_VN: Cổng
|
||||
es_ES: Puerto
|
||||
type: integer
|
||||
required: true
|
||||
default: 5600
|
||||
@@ -42,6 +76,10 @@ spec:
|
||||
label:
|
||||
en_US: Satori API Endpoint
|
||||
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
|
||||
required: true
|
||||
default: "http://localhost:5600/v1"
|
||||
@@ -49,6 +87,10 @@ spec:
|
||||
label:
|
||||
en_US: Satori WebSocket Endpoint
|
||||
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
|
||||
required: true
|
||||
default: "ws://localhost:5600/v1/events"
|
||||
@@ -56,6 +98,10 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
th_TH: โทเค็น
|
||||
vi_VN: Mã thông báo
|
||||
es_ES: Token
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -5,16 +5,58 @@ metadata:
|
||||
label:
|
||||
en_US: Slack
|
||||
zh_Hans: Slack
|
||||
zh_Hant: Slack
|
||||
ja_JP: Slack
|
||||
th_TH: Slack
|
||||
vi_VN: Slack
|
||||
es_ES: Slack
|
||||
description:
|
||||
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
|
||||
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:
|
||||
- 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
|
||||
label:
|
||||
en_US: Bot Token
|
||||
zh_Hans: 机器人令牌
|
||||
zh_Hant: 機器人令牌
|
||||
ja_JP: ボットトークン
|
||||
th_TH: โทเค็นบอท
|
||||
vi_VN: Mã thông báo Bot
|
||||
es_ES: Token del bot
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +64,11 @@ spec:
|
||||
label:
|
||||
en_US: signing_secret
|
||||
zh_Hans: 密钥
|
||||
zh_Hant: 密鑰
|
||||
ja_JP: 署名シークレット
|
||||
th_TH: คีย์ลายเซ็น
|
||||
vi_VN: Khóa ký
|
||||
es_ES: Secreto de firma
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -5,23 +5,50 @@ metadata:
|
||||
label:
|
||||
en_US: Telegram
|
||||
zh_Hans: 电报
|
||||
zh_Hant: Telegram
|
||||
ja_JP: Telegram
|
||||
th_TH: Telegram
|
||||
vi_VN: Telegram
|
||||
es_ES: Telegram
|
||||
description:
|
||||
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
|
||||
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:
|
||||
- name: token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
ja_JP: トークン
|
||||
th_TH: โทเค็น
|
||||
vi_VN: Mã thông báo
|
||||
es_ES: Token
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
default: "token_from_botfather"
|
||||
- name: markdown_card
|
||||
label:
|
||||
en_US: Markdown Card
|
||||
zh_Hans: 是否使用 Markdown 卡片
|
||||
zh_Hant: 是否使用 Markdown 卡片
|
||||
ja_JP: Markdown カードを使用
|
||||
th_TH: การ์ด Markdown
|
||||
vi_VN: Thẻ Markdown
|
||||
es_ES: Tarjeta Markdown
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
@@ -29,9 +56,19 @@ spec:
|
||||
label:
|
||||
en_US: Enable Stream Reply Mode
|
||||
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:
|
||||
en_US: If enabled, the bot will use the stream of telegram reply mode
|
||||
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
|
||||
required: true
|
||||
default: false
|
||||
|
||||
@@ -5,11 +5,21 @@ metadata:
|
||||
label:
|
||||
en_US: "WebSocket Chat"
|
||||
zh_Hans: "WebSocket 聊天"
|
||||
zh_Hant: "WebSocket 聊天"
|
||||
th_TH: "แชท WebSocket"
|
||||
vi_VN: "Trò chuyện WebSocket"
|
||||
es_ES: "Chat WebSocket"
|
||||
description:
|
||||
en_US: "WebSocket adapter for bidirectional real-time communication"
|
||||
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: ""
|
||||
spec:
|
||||
categories:
|
||||
- protocol
|
||||
config: []
|
||||
execution:
|
||||
python:
|
||||
|
||||
@@ -4,17 +4,26 @@ metadata:
|
||||
name: wechatpad
|
||||
label:
|
||||
en_US: WeChatPad
|
||||
zh_CN: WeChatPad(个人微信ipad)
|
||||
zh_Hans: WeChatPad(个人微信ipad)
|
||||
zh_Hant: WeChatPad(個人微信iPad)
|
||||
description:
|
||||
en_US: WeChatPad Adapter
|
||||
zh_CN: WeChatPad 适配器
|
||||
zh_Hans: WeChatPad 适配器,基于WeChatPad的个人微信解决方案,请查看文档了解使用方式
|
||||
zh_Hant: WeChatPad 適配器,基於 WeChatPad 的個人微信解決方案,請查看文件了解使用方式
|
||||
icon: wechatpad.png
|
||||
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:
|
||||
- name: wechatpad_url
|
||||
label:
|
||||
en_US: WeChatPad ERL
|
||||
zh_CN: WeChatPad URL
|
||||
zh_Hant: WeChatPad URL
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +31,7 @@ spec:
|
||||
label:
|
||||
en_US: WeChatPad_Ws
|
||||
zh_CN: WeChatPad_Ws
|
||||
zh_Hant: WeChatPad_Ws
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,6 +39,7 @@ spec:
|
||||
label:
|
||||
en_US: Admin_Key
|
||||
zh_CN: 管理员密匙
|
||||
zh_Hant: 管理員密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -36,6 +47,7 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_CN: 令牌
|
||||
zh_Hant: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -43,6 +55,7 @@ spec:
|
||||
label:
|
||||
en_US: wxid
|
||||
zh_CN: wxid
|
||||
zh_Hant: wxid
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
|
||||
@@ -5,16 +5,38 @@ metadata:
|
||||
label:
|
||||
en_US: WeCom
|
||||
zh_Hans: 企业微信
|
||||
zh_Hant: 企業微信
|
||||
description:
|
||||
en_US: WeCom Adapter
|
||||
zh_Hans: 企业微信适配器,请查看文档了解使用方式
|
||||
zh_Hans: 企业微信内部机器人,请查看文档了解使用方式
|
||||
zh_Hant: 企業微信內部機器人,請查看文件了解使用方式
|
||||
icon: wecom.png
|
||||
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:
|
||||
- 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
|
||||
label:
|
||||
en_US: Corpid
|
||||
zh_Hans: 企业ID
|
||||
zh_Hant: 企業ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +44,7 @@ spec:
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥 (Secret)
|
||||
zh_Hant: 密鑰 (Secret)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,6 +52,7 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌 (Token)
|
||||
zh_Hant: 令牌 (Token)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -36,6 +60,7 @@ spec:
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -43,9 +68,11 @@ spec:
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础 URL
|
||||
zh_Hant: API 基礎 URL
|
||||
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.
|
||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档填写此项
|
||||
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取企業微信 API,可根據文件填寫此項
|
||||
type: string
|
||||
required: false
|
||||
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
||||
|
||||
@@ -311,6 +311,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self.bot.on_message('single')(self.on_message)
|
||||
elif event_type == platform_events.GroupMessage:
|
||||
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:
|
||||
print(traceback.format_exc())
|
||||
|
||||
@@ -318,6 +321,50 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||
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
|
||||
|
||||
await self.logger.info(
|
||||
f'Feedback event: feedback_id={feedback_id}, type={feedback_type}, '
|
||||
f'session_id={session_id}, user_id={user_id}, message_id={message_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):
|
||||
_ws_mode = not self.config.get('enable-webhook', False)
|
||||
if _ws_mode:
|
||||
|
||||
@@ -5,16 +5,25 @@ metadata:
|
||||
label:
|
||||
en_US: WeComBot
|
||||
zh_Hans: 企业微信智能机器人
|
||||
zh_Hant: 企業微信智慧機器人
|
||||
description:
|
||||
en_US: WeComBot Adapter
|
||||
zh_Hans: 企业微信智能机器人适配器,请查看文档了解使用方式
|
||||
zh_Hans: 企业微信智能机器人,支持长连接和 Webhook 两种接入方式,请查看文档了解使用方式
|
||||
zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
|
||||
icon: wecombot.png
|
||||
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:
|
||||
- name: BotId
|
||||
label:
|
||||
en_US: BotId
|
||||
zh_Hans: 机器人ID (BotId)
|
||||
zh_Hant: 機器人ID (BotId)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +31,7 @@ spec:
|
||||
label:
|
||||
en_US: Robot Name
|
||||
zh_Hans: 机器人名称
|
||||
zh_Hant: 機器人名稱
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,19 +39,39 @@ spec:
|
||||
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: ""
|
||||
@@ -49,9 +79,11 @@ spec:
|
||||
label:
|
||||
en_US: Corpid
|
||||
zh_Hans: 企业ID
|
||||
zh_Hant: 企業ID
|
||||
description:
|
||||
en_US: Required for Webhook mode
|
||||
zh_Hans: 使用 Webhook 模式时必填
|
||||
zh_Hant: 使用 Webhook 模式時必填
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
@@ -59,9 +91,11 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌 (Token)
|
||||
zh_Hant: 令牌 (Token)
|
||||
description:
|
||||
en_US: Required for Webhook mode
|
||||
zh_Hans: 使用 Webhook 模式时必填
|
||||
zh_Hant: 使用 Webhook 模式時必填
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
@@ -69,9 +103,11 @@ spec:
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||
zh_Hant: 訊息加解密密鑰 (EncodingAESKey)
|
||||
description:
|
||||
en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption)
|
||||
zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选(用于文件解密)
|
||||
zh_Hant: 使用 Webhook 模式時必填。WebSocket 模式下可選(用於檔案解密)
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
@@ -79,9 +115,11 @@ spec:
|
||||
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
|
||||
|
||||
@@ -5,16 +5,37 @@ metadata:
|
||||
label:
|
||||
en_US: WeComCustomerService
|
||||
zh_Hans: 企业微信客服
|
||||
zh_Hant: 企業微信客服
|
||||
description:
|
||||
en_US: WeComCSAdapter
|
||||
zh_Hans: 企业微信客服适配器
|
||||
zh_Hans: 企业微信对外客服机器人,需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||
zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||
icon: wecom.png
|
||||
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:
|
||||
- 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
|
||||
label:
|
||||
en_US: Corpid
|
||||
zh_Hans: 企业ID
|
||||
zh_Hant: 企業ID
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -22,6 +43,7 @@ spec:
|
||||
label:
|
||||
en_US: Secret
|
||||
zh_Hans: 密钥
|
||||
zh_Hant: 密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -29,6 +51,7 @@ spec:
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌
|
||||
zh_Hant: 令牌
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -36,6 +59,7 @@ spec:
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥
|
||||
zh_Hant: 訊息加解密密鑰
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
@@ -43,9 +67,11 @@ spec:
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础 URL
|
||||
zh_Hant: API 基礎 URL
|
||||
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.
|
||||
zh_Hans: 可选,若您部署在内网环境并通过反向代理访问企业微信 API,可根据文档修改此项
|
||||
zh_Hant: 可選,若您部署在內網環境並透過反向代理存取企業微信 API,可根據文件修改此項
|
||||
type: string
|
||||
required: false
|
||||
default: "https://qyapi.weixin.qq.com/cgi-bin"
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import time
|
||||
import zipfile
|
||||
from typing import Any
|
||||
import typing
|
||||
import os
|
||||
@@ -192,6 +195,30 @@ class PluginRuntimeConnector:
|
||||
|
||||
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(
|
||||
self,
|
||||
install_source: PluginInstallSource,
|
||||
@@ -201,23 +228,44 @@ class PluginRuntimeConnector:
|
||||
if install_source == PluginInstallSource.LOCAL:
|
||||
# transfer file before install
|
||||
file_bytes = install_info['plugin_file']
|
||||
self._extract_deps_metadata(file_bytes, task_context)
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
del install_info['plugin_file']
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
elif install_source == PluginInstallSource.GITHUB:
|
||||
# download and transfer file
|
||||
# download and transfer file with streaming progress
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
follow_redirects=True,
|
||||
timeout=20,
|
||||
timeout=60,
|
||||
) as client:
|
||||
response = await client.get(
|
||||
install_info['asset_url'],
|
||||
)
|
||||
response.raise_for_status()
|
||||
file_bytes = response.content
|
||||
async with client.stream('GET', install_info['asset_url']) as response:
|
||||
response.raise_for_status()
|
||||
total = int(response.headers.get('content-length', 0))
|
||||
downloaded = 0
|
||||
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')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
@@ -236,6 +284,11 @@ class PluginRuntimeConnector:
|
||||
if task_context is not None:
|
||||
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(
|
||||
self,
|
||||
plugin_author: str,
|
||||
|
||||
@@ -2,7 +2,7 @@ import langbot
|
||||
|
||||
semantic_version = f'v{langbot.__version__}'
|
||||
|
||||
required_database_version = 24
|
||||
required_database_version = 26
|
||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||
|
||||
debug_mode = False
|
||||
|
||||
@@ -38,28 +38,31 @@ def get_frontend_path() -> str:
|
||||
"""
|
||||
Get the path to the frontend build files.
|
||||
|
||||
Returns the path to web/out directory, handling both:
|
||||
Returns the path to web/dist directory (Vite build output), handling both:
|
||||
- Development mode: running from source directory
|
||||
- Package mode: installed via pip/uvx
|
||||
- Legacy mode: web/out (Next.js, for backward compatibility)
|
||||
"""
|
||||
# First, check if we're running from source directory
|
||||
if _check_if_source_install() and os.path.exists('web/out'):
|
||||
return 'web/out'
|
||||
# Check both dist (Vite) and out (legacy Next.js) paths
|
||||
for dirname in ('dist', 'out'):
|
||||
web_dir = f'web/{dirname}'
|
||||
|
||||
# Second, check current directory for web/out (in case user is in source dir)
|
||||
if os.path.exists('web/out'):
|
||||
return 'web/out'
|
||||
# First, check if we're running from source directory
|
||||
if _check_if_source_install() and os.path.exists(web_dir):
|
||||
return web_dir
|
||||
|
||||
# Third, find it relative to the package installation
|
||||
# Get the directory where this file is located
|
||||
# paths.py is in pkg/utils/, so parent.parent goes up to pkg/, then parent again goes up to the package root
|
||||
pkg_dir = Path(__file__).parent.parent.parent
|
||||
frontend_path = pkg_dir / 'web' / 'out'
|
||||
if frontend_path.exists():
|
||||
return str(frontend_path)
|
||||
# Second, check current directory
|
||||
if os.path.exists(web_dir):
|
||||
return web_dir
|
||||
|
||||
# Third, find it relative to the package installation
|
||||
pkg_dir = Path(__file__).parent.parent.parent
|
||||
frontend_path = pkg_dir / 'web' / dirname
|
||||
if frontend_path.exists():
|
||||
return str(frontend_path)
|
||||
|
||||
# Return the default path (will be checked by caller)
|
||||
return 'web/out'
|
||||
return 'web/dist'
|
||||
|
||||
|
||||
def get_resource_path(resource: str) -> str:
|
||||
|
||||
@@ -203,7 +203,7 @@ class VersionManager:
|
||||
try:
|
||||
if await self.ap.ver_mgr.is_new_version_available():
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ system:
|
||||
edition: community
|
||||
recovery_key: ''
|
||||
allow_modify_login_info: true
|
||||
disabled_adapters: []
|
||||
limitation:
|
||||
max_bots: -1
|
||||
max_pipelines: -1
|
||||
|
||||
@@ -23,30 +23,30 @@ stages:
|
||||
label:
|
||||
en_US: Local Agent
|
||||
zh_Hans: 内置 Agent
|
||||
- name: tbox-app-api
|
||||
label:
|
||||
en_US: Tbox App API
|
||||
zh_Hans: 蚂蚁百宝箱平台 API
|
||||
- name: dify-service-api
|
||||
label:
|
||||
en_US: Dify Service API
|
||||
zh_Hans: Dify 服务 API
|
||||
- name: dashscope-app-api
|
||||
label:
|
||||
en_US: Aliyun Dashscope App API
|
||||
zh_Hans: 阿里云百炼平台 API
|
||||
- name: n8n-service-api
|
||||
label:
|
||||
en_US: n8n Workflow API
|
||||
zh_Hans: n8n 工作流 API
|
||||
- name: langflow-api
|
||||
label:
|
||||
en_US: Langflow API
|
||||
zh_Hans: Langflow API
|
||||
- name: coze-api
|
||||
label:
|
||||
en_US: Coze 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
|
||||
label:
|
||||
en_US: Local Agent
|
||||
@@ -104,28 +104,6 @@ stages:
|
||||
field: __system.is_wizard
|
||||
operator: neq
|
||||
value: true
|
||||
- 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: dify-service-api
|
||||
label:
|
||||
en_US: Dify Service API
|
||||
@@ -140,6 +118,11 @@ stages:
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
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
|
||||
label:
|
||||
@@ -178,54 +161,6 @@ stages:
|
||||
type: string
|
||||
required: true
|
||||
default: 'your-api-key'
|
||||
- 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: n8n-service-api
|
||||
label:
|
||||
en_US: n8n Workflow API
|
||||
@@ -375,6 +310,140 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
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
|
||||
label:
|
||||
en_US: Langflow API
|
||||
@@ -442,59 +511,4 @@ stages:
|
||||
zh_Hans: 可选的流程调整参数
|
||||
type: json
|
||||
required: false
|
||||
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
|
||||
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
|
||||
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
|
||||
default: '{}'
|
||||
0
tests/unit_tests/platform/__init__.py
Normal file
0
tests/unit_tests/platform/__init__.py
Normal file
280
tests/unit_tests/platform/test_routing_rules.py
Normal file
280
tests/unit_tests/platform/test_routing_rules.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
|
||||
"""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
||||
class TestMatchOperator:
|
||||
"""Test the _match_operator static method."""
|
||||
|
||||
@staticmethod
|
||||
def _get_class():
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
|
||||
return RuntimeBot
|
||||
|
||||
def test_eq(self):
|
||||
cls = self._get_class()
|
||||
assert cls._match_operator('hello', 'eq', 'hello') is True
|
||||
assert cls._match_operator('hello', 'eq', 'world') is False
|
||||
|
||||
def test_neq(self):
|
||||
cls = self._get_class()
|
||||
assert cls._match_operator('hello', 'neq', 'world') is True
|
||||
assert cls._match_operator('hello', 'neq', 'hello') is False
|
||||
|
||||
def test_contains(self):
|
||||
cls = self._get_class()
|
||||
assert cls._match_operator('hello world', 'contains', 'world') is True
|
||||
assert cls._match_operator('hello world', 'contains', 'xyz') is False
|
||||
|
||||
def test_not_contains(self):
|
||||
cls = self._get_class()
|
||||
assert cls._match_operator('hello world', 'not_contains', 'xyz') is True
|
||||
assert cls._match_operator('hello world', 'not_contains', 'world') is False
|
||||
|
||||
def test_starts_with(self):
|
||||
cls = self._get_class()
|
||||
assert cls._match_operator('hello world', 'starts_with', 'hello') is True
|
||||
assert cls._match_operator('hello world', 'starts_with', 'world') is False
|
||||
|
||||
def test_regex(self):
|
||||
cls = self._get_class()
|
||||
assert cls._match_operator('hello123', 'regex', r'\d+') is True
|
||||
assert cls._match_operator('hello', 'regex', r'\d+') is False
|
||||
|
||||
def test_regex_invalid_pattern(self):
|
||||
cls = self._get_class()
|
||||
assert cls._match_operator('hello', 'regex', r'[invalid') is False
|
||||
|
||||
def test_unknown_operator(self):
|
||||
cls = self._get_class()
|
||||
assert cls._match_operator('hello', 'unknown_op', 'hello') is False
|
||||
|
||||
|
||||
class TestResolvePipelineUuid:
|
||||
"""Test the resolve_pipeline_uuid method."""
|
||||
|
||||
@staticmethod
|
||||
def _make_bot(default_pipeline: str, rules: list):
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
|
||||
bot_entity = Mock()
|
||||
bot_entity.use_pipeline_uuid = default_pipeline
|
||||
bot_entity.pipeline_routing_rules = rules
|
||||
|
||||
bot = object.__new__(RuntimeBot)
|
||||
bot.bot_entity = bot_entity
|
||||
return bot
|
||||
|
||||
def test_no_rules_returns_default(self):
|
||||
bot = self._make_bot('default-uuid', [])
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||
assert uuid == 'default-uuid'
|
||||
assert routed is False
|
||||
|
||||
def test_none_rules_returns_default(self):
|
||||
bot = self._make_bot('default-uuid', None)
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||
assert uuid == 'default-uuid'
|
||||
assert routed is False
|
||||
|
||||
def test_launcher_type_match(self):
|
||||
rules = [
|
||||
{
|
||||
'type': 'launcher_type',
|
||||
'operator': 'eq',
|
||||
'value': 'group',
|
||||
'pipeline_uuid': 'group-pipeline',
|
||||
}
|
||||
]
|
||||
bot = self._make_bot('default-uuid', rules)
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi')
|
||||
assert uuid == 'group-pipeline'
|
||||
assert routed is True
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||
assert uuid == 'default-uuid'
|
||||
assert routed is False
|
||||
|
||||
def test_launcher_id_match(self):
|
||||
rules = [
|
||||
{
|
||||
'type': 'launcher_id',
|
||||
'operator': 'eq',
|
||||
'value': '12345',
|
||||
'pipeline_uuid': 'vip-pipeline',
|
||||
}
|
||||
]
|
||||
bot = self._make_bot('default-uuid', rules)
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '12345', 'hi')
|
||||
assert uuid == 'vip-pipeline'
|
||||
assert routed is True
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '99999', 'hi')
|
||||
assert uuid == 'default-uuid'
|
||||
assert routed is False
|
||||
|
||||
def test_message_content_contains(self):
|
||||
rules = [
|
||||
{
|
||||
'type': 'message_content',
|
||||
'operator': 'contains',
|
||||
'value': '紧急',
|
||||
'pipeline_uuid': 'urgent-pipeline',
|
||||
}
|
||||
]
|
||||
bot = self._make_bot('default-uuid', rules)
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '这是紧急消息')
|
||||
assert uuid == 'urgent-pipeline'
|
||||
assert routed is True
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '普通消息')
|
||||
assert uuid == 'default-uuid'
|
||||
assert routed is False
|
||||
|
||||
def test_message_content_regex(self):
|
||||
rules = [
|
||||
{
|
||||
'type': 'message_content',
|
||||
'operator': 'regex',
|
||||
'value': r'^/admin\b',
|
||||
'pipeline_uuid': 'admin-pipeline',
|
||||
}
|
||||
]
|
||||
bot = self._make_bot('default-uuid', rules)
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', '/admin help')
|
||||
assert uuid == 'admin-pipeline'
|
||||
assert routed is True
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hello /admin')
|
||||
assert uuid == 'default-uuid'
|
||||
assert routed is False
|
||||
|
||||
def test_message_has_element_eq(self):
|
||||
rules = [
|
||||
{
|
||||
'type': 'message_has_element',
|
||||
'operator': 'eq',
|
||||
'value': 'Image',
|
||||
'pipeline_uuid': 'image-pipeline',
|
||||
}
|
||||
]
|
||||
bot = self._make_bot('default-uuid', rules)
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image'])
|
||||
assert uuid == 'image-pipeline'
|
||||
assert routed is True
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain'])
|
||||
assert uuid == 'default-uuid'
|
||||
assert routed is False
|
||||
|
||||
def test_message_has_element_neq(self):
|
||||
rules = [
|
||||
{
|
||||
'type': 'message_has_element',
|
||||
'operator': 'neq',
|
||||
'value': 'Image',
|
||||
'pipeline_uuid': 'text-only-pipeline',
|
||||
}
|
||||
]
|
||||
bot = self._make_bot('default-uuid', rules)
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain'])
|
||||
assert uuid == 'text-only-pipeline'
|
||||
assert routed is True
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi', ['Plain', 'Image'])
|
||||
assert uuid == 'default-uuid'
|
||||
assert routed is False
|
||||
|
||||
def test_message_has_element_no_types_provided(self):
|
||||
"""When element types are not provided, should not match."""
|
||||
rules = [
|
||||
{
|
||||
'type': 'message_has_element',
|
||||
'operator': 'eq',
|
||||
'value': 'Image',
|
||||
'pipeline_uuid': 'image-pipeline',
|
||||
}
|
||||
]
|
||||
bot = self._make_bot('default-uuid', rules)
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||
assert uuid == 'default-uuid'
|
||||
assert routed is False
|
||||
|
||||
def test_first_match_wins(self):
|
||||
rules = [
|
||||
{
|
||||
'type': 'launcher_type',
|
||||
'operator': 'eq',
|
||||
'value': 'group',
|
||||
'pipeline_uuid': 'first-pipeline',
|
||||
},
|
||||
{
|
||||
'type': 'launcher_type',
|
||||
'operator': 'eq',
|
||||
'value': 'group',
|
||||
'pipeline_uuid': 'second-pipeline',
|
||||
},
|
||||
]
|
||||
bot = self._make_bot('default-uuid', rules)
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('group', '123', 'hi')
|
||||
assert uuid == 'first-pipeline'
|
||||
assert routed is True
|
||||
|
||||
def test_skip_invalid_rules(self):
|
||||
rules = [
|
||||
{'type': '', 'operator': 'eq', 'value': 'x', 'pipeline_uuid': 'p1'},
|
||||
{'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': ''},
|
||||
{'type': 'launcher_type', 'operator': 'eq', 'value': 'person', 'pipeline_uuid': 'valid'},
|
||||
]
|
||||
bot = self._make_bot('default-uuid', rules)
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||
assert uuid == 'valid'
|
||||
assert routed is True
|
||||
|
||||
def test_default_operator_is_eq(self):
|
||||
rules = [
|
||||
{
|
||||
'type': 'launcher_type',
|
||||
'value': 'person',
|
||||
'pipeline_uuid': 'person-pipeline',
|
||||
}
|
||||
]
|
||||
bot = self._make_bot('default-uuid', rules)
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'hi')
|
||||
assert uuid == 'person-pipeline'
|
||||
assert routed is True
|
||||
|
||||
def test_discard_pipeline(self):
|
||||
"""When pipeline_uuid is __discard__, the message should be discarded."""
|
||||
from langbot.pkg.platform.botmgr import RuntimeBot
|
||||
|
||||
rules = [
|
||||
{
|
||||
'type': 'message_content',
|
||||
'operator': 'contains',
|
||||
'value': 'spam',
|
||||
'pipeline_uuid': RuntimeBot.PIPELINE_DISCARD,
|
||||
}
|
||||
]
|
||||
bot = self._make_bot('default-uuid', rules)
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'this is spam')
|
||||
assert uuid == RuntimeBot.PIPELINE_DISCARD
|
||||
assert routed is True
|
||||
|
||||
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
|
||||
assert uuid == 'default-uuid'
|
||||
assert routed is False
|
||||
10
uv.lock
generated
10
uv.lock
generated
@@ -1832,7 +1832,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.9.4"
|
||||
version = "4.9.5"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -1937,7 +1937,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.3.5" },
|
||||
{ name = "langbot-plugin", specifier = "==0.3.7" },
|
||||
{ name = "langchain", specifier = ">=0.2.0" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||
@@ -1993,7 +1993,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.3.5"
|
||||
version = "0.3.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2011,9 +2011,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1c/8f/0a22e4461b0893ac2afb1b6aaebafe04c921df6dbbf4b8bd6c83cf6a97b2/langbot_plugin-0.3.5.tar.gz", hash = "sha256:79c7feb08f788f480435de8cdefc3cfed4de2dfb03978a460251b8c9d1c271d3", size = 171927, upload-time = "2026-03-25T13:53:18.334Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/31/8dc7106cb65004a01e363308343c5a95e35f1722f26c87853e6e12c6fee1/langbot_plugin-0.3.7.tar.gz", hash = "sha256:bc0dea6b1c515d9fc8c3ab14af74bdf3e006d7e20c097b6cb5034f5af4a73cc9", size = 179764, upload-time = "2026-04-03T09:43:17.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/93/fdd4eb54434a358a3917aec74190e2e1b64351a5bb955677f634d29fc4fd/langbot_plugin-0.3.5-py3-none-any.whl", hash = "sha256:4d31f92338e1e2dc343ae00982e4facbe7abae84f4d1c4e1375cdcac9d7155d7", size = 146575, upload-time = "2026-03-25T13:53:16.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/51/1982c199bd4efbfa3c327c95cca7e4ab502610251567000b348c72bca1b1/langbot_plugin-0.3.7-py3-none-any.whl", hash = "sha256:2e2b9e99163ceb14da28b8ce7c4cbc6990dea15684ec78976bc015e5378feea2", size = 157324, upload-time = "2026-04-03T09:43:15.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1 +1 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:5300
|
||||
VITE_API_BASE_URL=http://localhost:5300
|
||||
|
||||
1
web/.gitignore
vendored
1
web/.gitignore
vendored
@@ -14,6 +14,7 @@
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/dist/
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Debug LangBot Frontend
|
||||
|
||||
Please refer to the [Development Guide](https://docs.langbot.app/en/develop/dev-config.html) for more information.
|
||||
Please refer to the [Development Guide](https://link.langbot.app/en/docs/dev-config) for more information.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
|
||||
@@ -1,18 +1,27 @@
|
||||
import { dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
},
|
||||
},
|
||||
eslintPluginPrettierRecommended,
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
},
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
2
web/fix_router.sh
Normal file
2
web/fix_router.sh
Normal file
@@ -0,0 +1,2 @@
|
||||
sed -i 's/children={<HomePage \/>} />\n <HomePage \/>\n <\/HomeLayout>/g' src/router.tsx
|
||||
# well it's easier to recreate router.tsx
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>LangBot</title>
|
||||
<meta name="description" content="Production-grade platform for building agentic IM bots" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
web/migrate.sh
Executable file
29
web/migrate.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/bin/bash
|
||||
cd /root/.openclaw/workspace/coding/projects/LangBot/web
|
||||
|
||||
# Find and replace next/navigation
|
||||
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \
|
||||
-e "s/import {.*useRouter.*} from 'next\/navigation'/import { useNavigate } from 'react-router-dom'/g" \
|
||||
-e "s/import {.*usePathname.*} from 'next\/navigation'/import { useLocation } from 'react-router-dom'/g" \
|
||||
-e "s/import {.*useSearchParams.*} from 'next\/navigation'/import { useSearchParams } from 'react-router-dom'/g" \
|
||||
-e "s/const router = useRouter()/const navigate = useNavigate()/g" \
|
||||
-e "s/router\.push(/navigate(/g" \
|
||||
-e "s/router\.replace(/navigate(/g" \
|
||||
-e "s/router\.back()/navigate(-1)/g" \
|
||||
-e "s/router\.refresh()/navigate(0)/g" \
|
||||
-e "s/const pathname = usePathname()/const location = useLocation();\n const pathname = location.pathname;/g" \
|
||||
-e "s/usePathname()/useLocation().pathname/g" \
|
||||
{} +
|
||||
|
||||
# Note: useSearchParams returns a tuple in react-router-dom. This might need manual fix depending on usage.
|
||||
|
||||
# Replace next/link
|
||||
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i \
|
||||
-e "s/import Link from 'next\/link'/import { Link } from 'react-router-dom'/g" \
|
||||
-e "s/<Link href=/<Link to=/g" \
|
||||
{} +
|
||||
|
||||
# Remove 'use client'
|
||||
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i "s/'use client';//g" {} +
|
||||
find src -type f \( -name "*.ts" -o -name "*.tsx" \) -exec sed -i 's/"use client";//g' {} +
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: 'export',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
4340
web/package-lock.json
generated
4340
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -3,16 +3,15 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint src",
|
||||
"lint:fix": "eslint src --fix",
|
||||
"lint-staged": "lint-staged"
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,jsx,ts,tsx}": [
|
||||
"next lint --fix",
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
@@ -34,6 +33,7 @@
|
||||
"@radix-ui/react-hover-card": "^1.1.13",
|
||||
"@radix-ui/react-label": "^2.1.6",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.4",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
@@ -45,6 +45,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tailwindcss/postcss": "^4.1.5",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"axios": "^1.13.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -54,8 +55,6 @@
|
||||
"input-otp": "^1.4.2",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.507.0",
|
||||
"next": "~16.1.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"postcss": "^8.5.3",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.1",
|
||||
@@ -64,6 +63,7 @@
|
||||
"react-i18next": "^15.5.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-router-dom": "^7.14.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"recharts": "2.15.4",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
@@ -76,10 +76,10 @@
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss": "^4.1.5",
|
||||
"uuidjs": "^5.1.0",
|
||||
"vite": "^8.0.3",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/estree": "^1.0.8",
|
||||
"@types/estree-jsx": "^1.0.5",
|
||||
@@ -94,9 +94,10 @@
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/unist": "^3.0.3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.2.4",
|
||||
"eslint-config-prettier": "^10.1.2",
|
||||
"eslint-plugin-prettier": "^5.2.6",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"lint-staged": "^15.5.1",
|
||||
"prettier": "^3.5.3",
|
||||
"tw-animate-css": "^1.2.9",
|
||||
|
||||
5592
web/pnpm-lock.yaml
generated
5592
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, useCallback, Suspense } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -23,8 +21,8 @@ import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
import langbotIcon from '@/app/assets/langbot-logo.webp';
|
||||
|
||||
function SpaceOAuthCallbackContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [status, setStatus] = useState<
|
||||
@@ -51,7 +49,7 @@ function SpaceOAuthCallbackContent() {
|
||||
const wizardState = localStorage.getItem('langbot_wizard_state');
|
||||
const redirectTo = wizardState ? '/wizard' : '/home';
|
||||
setTimeout(() => {
|
||||
router.push(redirectTo);
|
||||
navigate(redirectTo);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
@@ -64,7 +62,7 @@ function SpaceOAuthCallbackContent() {
|
||||
}
|
||||
}
|
||||
},
|
||||
[router, t],
|
||||
[navigate, t],
|
||||
);
|
||||
|
||||
const [bindState, setBindState] = useState<string | null>(null);
|
||||
@@ -81,7 +79,7 @@ function SpaceOAuthCallbackContent() {
|
||||
setStatus('success');
|
||||
toast.success(t('account.bindSpaceSuccess'));
|
||||
setTimeout(() => {
|
||||
router.push('/home');
|
||||
navigate('/home');
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
@@ -96,7 +94,7 @@ function SpaceOAuthCallbackContent() {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
},
|
||||
[router, t],
|
||||
[navigate, t],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -146,7 +144,7 @@ function SpaceOAuthCallbackContent() {
|
||||
};
|
||||
|
||||
const handleCancelBind = () => {
|
||||
router.push('/home');
|
||||
navigate('/home');
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -154,7 +152,7 @@ function SpaceOAuthCallbackContent() {
|
||||
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
|
||||
<CardHeader className="text-center">
|
||||
<img
|
||||
src={langbotIcon.src}
|
||||
src={langbotIcon}
|
||||
alt="LangBot"
|
||||
className="w-16 h-16 mb-4 mx-auto"
|
||||
/>
|
||||
@@ -217,7 +215,7 @@ function SpaceOAuthCallbackContent() {
|
||||
<>
|
||||
<AlertCircle className="h-12 w-12 text-red-500" />
|
||||
<Button
|
||||
onClick={() => router.push(isBindMode ? '/home' : '/login')}
|
||||
onClick={() => navigate(isBindMode ? '/home' : '/login')}
|
||||
className="w-full mt-4"
|
||||
>
|
||||
{isBindMode ? t('common.backToHome') : t('common.backToLogin')}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
:root {
|
||||
/* 适用于 Firefox 的滚动条 */
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
|
||||
@@ -72,9 +74,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
@@ -34,7 +32,7 @@ import { toast } from 'sonner';
|
||||
|
||||
export default function BotDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
|
||||
|
||||
@@ -105,12 +103,12 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
|
||||
function handleBotDeleted() {
|
||||
refreshBots();
|
||||
router.push('/home/bots');
|
||||
navigate('/home/bots');
|
||||
}
|
||||
|
||||
function handleNewBotCreated(newBotId: string) {
|
||||
refreshBots();
|
||||
router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
||||
navigate(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
@@ -176,9 +174,11 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" form="bot-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{activeTab === 'config' && (
|
||||
<Button type="submit" form="bot-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import i18n from 'i18next';
|
||||
import {
|
||||
IChooseAdapterEntity,
|
||||
IPipelineEntity,
|
||||
@@ -13,15 +14,16 @@ import { UUID } from 'uuidjs';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Bot } from '@/app/infra/entities/api';
|
||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import RoutingRulesEditor from './RoutingRulesEditor';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -37,6 +39,7 @@ import {
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
@@ -49,6 +52,10 @@ import {
|
||||
} from '@/components/ui/card';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import {
|
||||
groupByCategory,
|
||||
getCategoryLabel,
|
||||
} from '@/app/infra/entities/adapter-categories';
|
||||
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
@@ -58,6 +65,28 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
adapter_config: z.record(z.string(), z.any()),
|
||||
enable: z.boolean().optional(),
|
||||
use_pipeline_uuid: z.string().optional(),
|
||||
pipeline_routing_rules: z
|
||||
.array(
|
||||
z.object({
|
||||
type: z.enum([
|
||||
'launcher_type',
|
||||
'launcher_id',
|
||||
'message_content',
|
||||
'message_has_element',
|
||||
]),
|
||||
operator: z.enum([
|
||||
'eq',
|
||||
'neq',
|
||||
'contains',
|
||||
'not_contains',
|
||||
'starts_with',
|
||||
'regex',
|
||||
]),
|
||||
value: z.string(),
|
||||
pipeline_uuid: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export default function BotForm({
|
||||
@@ -83,6 +112,7 @@ export default function BotForm({
|
||||
adapter_config: {},
|
||||
enable: true,
|
||||
use_pipeline_uuid: '',
|
||||
pipeline_routing_rules: [],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -99,6 +129,9 @@ export default function BotForm({
|
||||
const [adapterDescriptionList, setAdapterDescriptionList] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [adapterHelpLinks, setAdapterHelpLinks] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
|
||||
const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(
|
||||
[],
|
||||
@@ -110,29 +143,16 @@ export default function BotForm({
|
||||
const [, setIsLoading] = useState<boolean>(false);
|
||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
||||
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
||||
const [copied, setCopied] = useState<boolean>(false);
|
||||
const [extraCopied, setExtraCopied] = useState<boolean>(false);
|
||||
|
||||
// Watch adapter and adapter_config for filtering
|
||||
const currentAdapter = form.watch('adapter');
|
||||
const currentAdapterConfig = form.watch('adapter_config');
|
||||
|
||||
// Derive the filtered config list via useMemo instead of useEffect+setState
|
||||
// to avoid creating new array references that would cause DynamicFormComponent
|
||||
// to re-subscribe its form.watch, re-emit values, and trigger an infinite loop.
|
||||
// Only depend on the specific field we care about (enable-webhook) rather than
|
||||
// the entire currentAdapterConfig object, which changes on every emission.
|
||||
const enableWebhook = currentAdapterConfig?.['enable-webhook'];
|
||||
const filteredDynamicFormConfigList = useMemo(() => {
|
||||
if (currentAdapter === 'lark' && enableWebhook === false) {
|
||||
// Hide encrypt-key field when webhook is disabled
|
||||
return dynamicFormConfigList.filter(
|
||||
(config) => config.name !== 'encrypt-key',
|
||||
);
|
||||
}
|
||||
// For non-Lark adapters or when webhook is enabled/undefined, show all fields
|
||||
return dynamicFormConfigList;
|
||||
}, [currentAdapter, enableWebhook, dynamicFormConfigList]);
|
||||
// Group adapters by category for the Select dropdown
|
||||
const groupedAdapters = useMemo(
|
||||
() => groupByCategory(adapterNameList),
|
||||
[adapterNameList],
|
||||
);
|
||||
|
||||
// Notify parent when dirty state changes
|
||||
const { isDirty } = form.formState;
|
||||
@@ -144,43 +164,6 @@ export default function BotForm({
|
||||
setBotFormValues();
|
||||
}, []);
|
||||
|
||||
const copyToClipboard = (
|
||||
text: string,
|
||||
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
) => {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setStatus(true);
|
||||
setTimeout(() => setStatus(false), 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
fallbackCopy(text, setStatus);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(text, setStatus);
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackCopy = (
|
||||
text: string,
|
||||
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
||||
) => {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
if (successful) {
|
||||
setStatus(true);
|
||||
setTimeout(() => setStatus(false), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
function setBotFormValues() {
|
||||
isInitializing.current = true;
|
||||
initBotFormComponent().then(() => {
|
||||
@@ -196,6 +179,7 @@ export default function BotForm({
|
||||
adapter_config: val.adapter_config,
|
||||
enable: val.enable,
|
||||
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
||||
pipeline_routing_rules: val.pipeline_routing_rules || [],
|
||||
});
|
||||
handleAdapterSelect(val.adapter);
|
||||
|
||||
@@ -241,6 +225,7 @@ export default function BotForm({
|
||||
return {
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
categories: item.spec.categories,
|
||||
};
|
||||
}),
|
||||
);
|
||||
@@ -255,6 +240,18 @@ export default function BotForm({
|
||||
),
|
||||
);
|
||||
|
||||
setAdapterHelpLinks(
|
||||
adaptersRes.adapters.reduce(
|
||||
(acc, item) => {
|
||||
if (item.spec.help_links) {
|
||||
acc[item.name] = item.spec.help_links;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Record<string, string>>,
|
||||
),
|
||||
);
|
||||
|
||||
adaptersRes.adapters.forEach((rawAdapter) => {
|
||||
adapterNameToDynamicConfigMap.set(
|
||||
rawAdapter.name,
|
||||
@@ -298,6 +295,7 @@ export default function BotForm({
|
||||
adapter_config: bot.adapter_config,
|
||||
enable: bot.enable ?? true,
|
||||
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
||||
pipeline_routing_rules: bot.pipeline_routing_rules ?? [],
|
||||
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||
| string
|
||||
| undefined,
|
||||
@@ -342,6 +340,7 @@ export default function BotForm({
|
||||
adapter_config: form.getValues().adapter_config,
|
||||
enable: form.getValues().enable,
|
||||
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
|
||||
pipeline_routing_rules: form.getValues().pipeline_routing_rules ?? [],
|
||||
};
|
||||
httpClient
|
||||
.updateBot(initBotId, updateBot)
|
||||
@@ -384,12 +383,6 @@ export default function BotForm({
|
||||
}
|
||||
}
|
||||
|
||||
// --- Webhook URL display helper ---
|
||||
const showWebhook =
|
||||
initBotId &&
|
||||
webhookUrl &&
|
||||
(currentAdapter !== 'lark' || enableWebhook !== false);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -498,6 +491,12 @@ export default function BotForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Pipeline Routing Rules */}
|
||||
<RoutingRulesEditor
|
||||
form={form}
|
||||
pipelineNameList={pipelineNameList}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
@@ -521,48 +520,81 @@ export default function BotForm({
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleAdapterSelect(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
{field.value ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(field.value)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleAdapterSelect(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-[240px]">
|
||||
{field.value ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(field.value)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>
|
||||
{adapterNameList.find(
|
||||
(a) => a.value === field.value,
|
||||
)?.label ?? field.value}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={t('bots.selectAdapter')}
|
||||
/>
|
||||
<span>
|
||||
{adapterNameList.find(
|
||||
(a) => a.value === field.value,
|
||||
)?.label ?? field.value}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<SelectValue placeholder={t('bots.selectAdapter')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{adapterNameList.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(item.value)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupedAdapters.map((group) => (
|
||||
<SelectGroup
|
||||
key={group.categoryId ?? 'uncategorized'}
|
||||
>
|
||||
{group.categoryId && (
|
||||
<SelectLabel>
|
||||
{getCategoryLabel(t, group.categoryId)}
|
||||
</SelectLabel>
|
||||
)}
|
||||
{group.items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getAdapterIconURL(
|
||||
item.value,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{currentAdapter &&
|
||||
(() => {
|
||||
const docUrl = getAdapterDocUrl(
|
||||
adapterHelpLinks[currentAdapter],
|
||||
i18n.language,
|
||||
);
|
||||
return docUrl ? (
|
||||
<a
|
||||
href={docUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center gap-1 text-xs text-primary hover:underline"
|
||||
>
|
||||
{t('bots.viewAdapterDocs')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
</FormControl>
|
||||
{currentAdapter && adapterDescriptionList[currentAdapter] && (
|
||||
<FormDescription>
|
||||
@@ -574,75 +606,19 @@ export default function BotForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Webhook URL: shown after adapter is selected (edit mode only) */}
|
||||
{showWebhook && (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-muted"
|
||||
onClick={(e) => {
|
||||
(e.target as HTMLInputElement).select();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(webhookUrl, setCopied)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{extraWebhookUrl && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
value={extraWebhookUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-muted"
|
||||
onClick={(e) => {
|
||||
(e.target as HTMLInputElement).select();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(extraWebhookUrl, setExtraCopied)
|
||||
}
|
||||
>
|
||||
{extraCopied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<FormDescription>
|
||||
{extraWebhookUrl
|
||||
? t('bots.webhookUrlHintEither')
|
||||
: t('bots.webhookUrlHint')}
|
||||
</FormDescription>
|
||||
</FormItem>
|
||||
)}
|
||||
|
||||
{showDynamicForm && filteredDynamicFormConfigList.length > 0 && (
|
||||
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
||||
<DynamicFormComponent
|
||||
itemConfigList={filteredDynamicFormConfigList}
|
||||
itemConfigList={dynamicFormConfigList}
|
||||
initialValues={currentAdapterConfig}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
}}
|
||||
systemContext={{
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export interface IChooseAdapterEntity {
|
||||
label: string;
|
||||
value: string;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export interface IPipelineEntity {
|
||||
|
||||
480
web/src/app/home/bots/components/bot-form/RoutingRulesEditor.tsx
Normal file
480
web/src/app/home/bots/components/bot-form/RoutingRulesEditor.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
'use client';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { UseFormReturn } from 'react-hook-form';
|
||||
import {
|
||||
PipelineRoutingRule,
|
||||
RoutingRuleOperator,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Ban, GripVertical, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { FormLabel } from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
KeyboardSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragStartEvent,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useRef, useMemo, useState } from 'react';
|
||||
|
||||
export const PIPELINE_DISCARD = '__discard__';
|
||||
|
||||
interface PipelineOption {
|
||||
value: string;
|
||||
label: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
interface RoutingRulesEditorProps {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
form: UseFormReturn<any>;
|
||||
pipelineNameList: PipelineOption[];
|
||||
}
|
||||
|
||||
const OPERATORS_BY_TYPE: Record<
|
||||
PipelineRoutingRule['type'],
|
||||
{ value: RoutingRuleOperator; labelKey: string }[]
|
||||
> = {
|
||||
launcher_type: [
|
||||
{ value: 'eq', labelKey: 'bots.operatorEq' },
|
||||
{ value: 'neq', labelKey: 'bots.operatorNeq' },
|
||||
],
|
||||
launcher_id: [
|
||||
{ value: 'eq', labelKey: 'bots.operatorEq' },
|
||||
{ value: 'neq', labelKey: 'bots.operatorNeq' },
|
||||
{ value: 'contains', labelKey: 'bots.operatorContains' },
|
||||
{ value: 'not_contains', labelKey: 'bots.operatorNotContains' },
|
||||
{ value: 'regex', labelKey: 'bots.operatorRegex' },
|
||||
],
|
||||
message_content: [
|
||||
{ value: 'eq', labelKey: 'bots.operatorEq' },
|
||||
{ value: 'neq', labelKey: 'bots.operatorNeq' },
|
||||
{ value: 'contains', labelKey: 'bots.operatorContains' },
|
||||
{ value: 'not_contains', labelKey: 'bots.operatorNotContains' },
|
||||
{ value: 'starts_with', labelKey: 'bots.operatorStartsWith' },
|
||||
{ value: 'regex', labelKey: 'bots.operatorRegex' },
|
||||
],
|
||||
message_has_element: [
|
||||
{ value: 'eq', labelKey: 'bots.operatorHas' },
|
||||
{ value: 'neq', labelKey: 'bots.operatorNotHas' },
|
||||
],
|
||||
};
|
||||
|
||||
function getValuePlaceholder(
|
||||
t: (key: string) => string,
|
||||
rule: PipelineRoutingRule,
|
||||
): string {
|
||||
if (rule.type === 'launcher_id')
|
||||
return t('bots.ruleValueLauncherIdPlaceholder');
|
||||
if (rule.type === 'message_has_element')
|
||||
return t('bots.ruleValueElementPlaceholder');
|
||||
if (rule.operator === 'regex') return t('bots.ruleValueRegexpPlaceholder');
|
||||
return t('bots.ruleValueMessagePlaceholder');
|
||||
}
|
||||
|
||||
/* ── Static rule row (used in DragOverlay) ─────────────────────────── */
|
||||
|
||||
interface RuleRowContentProps {
|
||||
rule: PipelineRoutingRule;
|
||||
index: number;
|
||||
pipelineNameList: PipelineOption[];
|
||||
updateRule: (index: number, patch: Partial<PipelineRoutingRule>) => void;
|
||||
removeRule: (index: number) => void;
|
||||
dragHandleProps?: Record<string, unknown>;
|
||||
isOverlay?: boolean;
|
||||
}
|
||||
|
||||
function RuleRowContent({
|
||||
rule,
|
||||
index,
|
||||
pipelineNameList,
|
||||
updateRule,
|
||||
removeRule,
|
||||
dragHandleProps,
|
||||
isOverlay,
|
||||
}: RuleRowContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const operatorsForType =
|
||||
OPERATORS_BY_TYPE[rule.type] || OPERATORS_BY_TYPE.message_content;
|
||||
const isDiscard = rule.pipeline_uuid === PIPELINE_DISCARD;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 mt-2 p-3 border rounded-md bg-muted/30 ${
|
||||
isOverlay ? 'shadow-lg ring-2 ring-primary/20 bg-background' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground touch-none"
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Field selector */}
|
||||
<Select
|
||||
value={rule.type}
|
||||
onValueChange={(val) => {
|
||||
updateRule(index, {
|
||||
type: val as PipelineRoutingRule['type'],
|
||||
operator: 'eq',
|
||||
value: '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="launcher_type">
|
||||
{t('bots.ruleTypeLauncherType')}
|
||||
</SelectItem>
|
||||
<SelectItem value="launcher_id">
|
||||
{t('bots.ruleTypeLauncherId')}
|
||||
</SelectItem>
|
||||
<SelectItem value="message_content">
|
||||
{t('bots.ruleTypeMessageContent')}
|
||||
</SelectItem>
|
||||
<SelectItem value="message_has_element">
|
||||
{t('bots.ruleTypeMessageHasElement')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Operator selector */}
|
||||
<Select
|
||||
value={rule.operator || 'eq'}
|
||||
onValueChange={(val) => {
|
||||
updateRule(index, { operator: val as RoutingRuleOperator });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{operatorsForType.map((op) => (
|
||||
<SelectItem key={op.value} value={op.value}>
|
||||
{t(op.labelKey)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Value input */}
|
||||
{rule.type === 'launcher_type' ? (
|
||||
<Select
|
||||
value={rule.value}
|
||||
onValueChange={(val) => updateRule(index, { value: val })}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder={t('bots.ruleValuePlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="person">
|
||||
{t('bots.sessionTypePerson')}
|
||||
</SelectItem>
|
||||
<SelectItem value="group">{t('bots.sessionTypeGroup')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : rule.type === 'message_has_element' ? (
|
||||
<Select
|
||||
value={rule.value}
|
||||
onValueChange={(val) => updateRule(index, { value: val })}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder={t('bots.ruleValueElementPlaceholder')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Image">{t('bots.elementImage')}</SelectItem>
|
||||
<SelectItem value="Voice">{t('bots.elementVoice')}</SelectItem>
|
||||
<SelectItem value="File">{t('bots.elementFile')}</SelectItem>
|
||||
<SelectItem value="Forward">{t('bots.elementForward')}</SelectItem>
|
||||
<SelectItem value="Face">{t('bots.elementFace')}</SelectItem>
|
||||
<SelectItem value="At">{t('bots.elementAt')}</SelectItem>
|
||||
<SelectItem value="AtAll">{t('bots.elementAtAll')}</SelectItem>
|
||||
<SelectItem value="Quote">{t('bots.elementQuote')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
className="flex-1"
|
||||
placeholder={getValuePlaceholder(t, rule)}
|
||||
value={rule.value}
|
||||
onChange={(e) => updateRule(index, { value: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="text-sm text-muted-foreground shrink-0">→</span>
|
||||
|
||||
{/* Pipeline selector */}
|
||||
<Select
|
||||
value={rule.pipeline_uuid}
|
||||
onValueChange={(val) => updateRule(index, { pipeline_uuid: val })}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
{rule.pipeline_uuid ? (
|
||||
isDiscard ? (
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<Ban className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t('bots.pipelineDiscard')}</span>
|
||||
</div>
|
||||
) : (
|
||||
(() => {
|
||||
const p = pipelineNameList.find(
|
||||
(p) => p.value === rule.pipeline_uuid,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{p?.emoji && (
|
||||
<span className="text-sm shrink-0">{p.emoji}</span>
|
||||
)}
|
||||
<span>{p?.label ?? rule.pipeline_uuid}</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)
|
||||
) : (
|
||||
<SelectValue placeholder={t('bots.selectPipeline')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={PIPELINE_DISCARD}>
|
||||
<div className="flex items-center gap-2 text-destructive">
|
||||
<Ban className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>{t('bots.pipelineDiscard')}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectSeparator />
|
||||
{pipelineNameList.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
{item.emoji && (
|
||||
<span className="text-sm shrink-0">{item.emoji}</span>
|
||||
)}
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={() => removeRule(index)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Sortable rule row ─────────────────────────────────────────────── */
|
||||
|
||||
interface SortableRuleRowProps {
|
||||
id: string;
|
||||
rule: PipelineRoutingRule;
|
||||
index: number;
|
||||
pipelineNameList: PipelineOption[];
|
||||
updateRule: (index: number, patch: Partial<PipelineRoutingRule>) => void;
|
||||
removeRule: (index: number) => void;
|
||||
}
|
||||
|
||||
function SortableRuleRow({
|
||||
id,
|
||||
rule,
|
||||
index,
|
||||
pipelineNameList,
|
||||
updateRule,
|
||||
removeRule,
|
||||
}: SortableRuleRowProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
// No transition — items reorder visually during drag via transform;
|
||||
// on drop the data updates and transform resets, so animating would
|
||||
// cause a redundant "swap" flicker.
|
||||
opacity: isDragging ? 0.3 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style}>
|
||||
<RuleRowContent
|
||||
rule={rule}
|
||||
index={index}
|
||||
pipelineNameList={pipelineNameList}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
dragHandleProps={{ ...attributes, ...listeners }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main editor ───────────────────────────────────────────────────── */
|
||||
|
||||
export default function RoutingRulesEditor({
|
||||
form,
|
||||
pipelineNameList,
|
||||
}: RoutingRulesEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const rules: PipelineRoutingRule[] =
|
||||
form.watch('pipeline_routing_rules') || [];
|
||||
|
||||
// Stable unique ids for sortable items.
|
||||
// We keep a running counter so newly added rules always get fresh ids.
|
||||
const nextId = useRef(0);
|
||||
const idsRef = useRef<string[]>([]);
|
||||
|
||||
const sortableIds = useMemo(() => {
|
||||
// Grow the id list to match rules length (newly added items get new ids).
|
||||
while (idsRef.current.length < rules.length) {
|
||||
idsRef.current.push(`rule-${nextId.current++}`);
|
||||
}
|
||||
// Shrink if rules were removed from the end.
|
||||
if (idsRef.current.length > rules.length) {
|
||||
idsRef.current = idsRef.current.slice(0, rules.length);
|
||||
}
|
||||
return idsRef.current;
|
||||
}, [rules.length]);
|
||||
|
||||
const updateRules = (newRules: PipelineRoutingRule[]) => {
|
||||
form.setValue('pipeline_routing_rules', newRules, { shouldDirty: true });
|
||||
};
|
||||
|
||||
const addRule = () => {
|
||||
updateRules([
|
||||
...rules,
|
||||
{
|
||||
type: 'launcher_type',
|
||||
operator: 'eq',
|
||||
value: '',
|
||||
pipeline_uuid: '',
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const updateRule = (index: number, patch: Partial<PipelineRoutingRule>) => {
|
||||
const updated = [...rules];
|
||||
updated[index] = { ...updated[index], ...patch };
|
||||
updateRules(updated);
|
||||
};
|
||||
|
||||
const removeRule = (index: number) => {
|
||||
const updated = [...rules];
|
||||
updated.splice(index, 1);
|
||||
// Also remove the corresponding sortable id so indices stay in sync.
|
||||
idsRef.current.splice(index, 1);
|
||||
updateRules(updated);
|
||||
};
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
const oldIndex = sortableIds.indexOf(active.id as string);
|
||||
const newIndex = sortableIds.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) return;
|
||||
|
||||
idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
|
||||
updateRules(arrayMove(rules, oldIndex, newIndex));
|
||||
};
|
||||
|
||||
const activeIndex = activeId ? sortableIds.indexOf(activeId) : -1;
|
||||
const activeRule = activeIndex >= 0 ? rules[activeIndex] : null;
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<FormLabel>{t('bots.routingRules')}</FormLabel>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t('bots.routingRulesDescription')}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="sm" onClick={addRule}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('bots.addRoutingRule')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortableIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{rules.map((rule, index) => (
|
||||
<SortableRuleRow
|
||||
key={sortableIds[index]}
|
||||
id={sortableIds[index]}
|
||||
rule={rule}
|
||||
index={index}
|
||||
pipelineNameList={pipelineNameList}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeRule ? (
|
||||
<RuleRowContent
|
||||
rule={activeRule}
|
||||
index={activeIndex}
|
||||
pipelineNameList={pipelineNameList}
|
||||
updateRule={updateRule}
|
||||
removeRule={removeRule}
|
||||
isOverlay
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { BotLogManager } from '@/app/home/bots/components/bot-log/BotLogManager';
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
@@ -15,7 +13,7 @@ import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ChevronDownIcon, ExternalLink } from 'lucide-react';
|
||||
import { debounce } from 'lodash';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export function BotLogListComponent({
|
||||
botId,
|
||||
@@ -32,7 +30,7 @@ export function BotLogListComponent({
|
||||
hideToolbar?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const manager = useRef(new BotLogManager(botId)).current;
|
||||
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
|
||||
const [autoFlush, setAutoFlush] = useState(true);
|
||||
@@ -231,7 +229,7 @@ export function BotLogListComponent({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
|
||||
onClick={() => navigate(`/home/monitoring?botId=${botId}`)}
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
@@ -12,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import { Ban, Bot, Copy, Check, Workflow, UserCheck, Send } from 'lucide-react';
|
||||
import {
|
||||
MessageChainComponent,
|
||||
Plain,
|
||||
@@ -21,6 +19,7 @@ import {
|
||||
Quote,
|
||||
Voice,
|
||||
} from '@/app/infra/entities/message';
|
||||
import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/RoutingRulesEditor';
|
||||
|
||||
interface SessionInfo {
|
||||
session_id: string;
|
||||
@@ -78,6 +77,16 @@ const BotSessionMonitor = forwardRef<
|
||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Human takeover state
|
||||
const [isTakenOver, setIsTakenOver] = useState(false);
|
||||
const [takeoverLoading, setTakeoverLoading] = useState(false);
|
||||
const [operatorMessage, setOperatorMessage] = useState('');
|
||||
const [sendingMessage, setSendingMessage] = useState(false);
|
||||
// Track which sessions are taken over for showing badges in the list
|
||||
const [takenOverSessions, setTakenOverSessions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const parseSessionType = (sessionId: string): string | null => {
|
||||
const idx = sessionId.indexOf('_');
|
||||
if (idx === -1) return null;
|
||||
@@ -110,6 +119,24 @@ const BotSessionMonitor = forwardRef<
|
||||
}
|
||||
}, [botId]);
|
||||
|
||||
// Load active takeover sessions to know which ones show a badge
|
||||
const loadTakeoverStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await httpClient.getHumanTakeoverSessions({
|
||||
botUuid: botId,
|
||||
});
|
||||
const activeIds = new Set<string>();
|
||||
for (const session of response.sessions ?? []) {
|
||||
if (session.status === 'active') {
|
||||
activeIds.add(session.session_id);
|
||||
}
|
||||
}
|
||||
setTakenOverSessions(activeIds);
|
||||
} catch {
|
||||
// Silently ignore — takeover feature may not be available
|
||||
}
|
||||
}, [botId]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -134,29 +161,131 @@ const BotSessionMonitor = forwardRef<
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check takeover status for selected session
|
||||
const checkTakeoverStatus = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
const response =
|
||||
await httpClient.getHumanTakeoverSessionDetail(sessionId);
|
||||
const isActive =
|
||||
response.found && response.session?.status === 'active';
|
||||
setIsTakenOver(isActive);
|
||||
} catch {
|
||||
setIsTakenOver(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
loadTakeoverStatus();
|
||||
}, [loadSessions, loadTakeoverStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSessionId) {
|
||||
loadMessages(selectedSessionId);
|
||||
checkTakeoverStatus(selectedSessionId);
|
||||
} else {
|
||||
setMessages([]);
|
||||
setIsTakenOver(false);
|
||||
}
|
||||
}, [selectedSessionId, loadMessages]);
|
||||
}, [selectedSessionId, loadMessages, checkTakeoverStatus]);
|
||||
|
||||
// Auto-refresh messages when session is taken over (polling)
|
||||
useEffect(() => {
|
||||
if (!selectedSessionId || !isTakenOver) return;
|
||||
const interval = setInterval(() => {
|
||||
loadMessages(selectedSessionId);
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedSessionId, isTakenOver, loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (container) {
|
||||
const viewport = container.querySelector(
|
||||
'[data-radix-scroll-area-viewport]',
|
||||
);
|
||||
const scrollTarget = viewport || container;
|
||||
scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
||||
}
|
||||
if (messages.length === 0) return;
|
||||
// Wait for DOM to render the new messages before scrolling
|
||||
requestAnimationFrame(() => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (container) {
|
||||
const viewport = container.querySelector(
|
||||
'[data-radix-scroll-area-viewport]',
|
||||
);
|
||||
const scrollTarget = viewport || container;
|
||||
scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
||||
}
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const handleTakeover = async () => {
|
||||
if (!selectedSessionId || !selectedSession) return;
|
||||
if (!confirm(t('bots.sessionMonitor.takeoverConfirm'))) return;
|
||||
|
||||
setTakeoverLoading(true);
|
||||
try {
|
||||
await httpClient.takeoverSession(selectedSessionId, {
|
||||
bot_uuid: botId,
|
||||
platform: selectedSession.platform ?? undefined,
|
||||
user_id: selectedSession.user_id ?? undefined,
|
||||
user_name: selectedSession.user_name ?? undefined,
|
||||
});
|
||||
setIsTakenOver(true);
|
||||
setTakenOverSessions((prev) => new Set(prev).add(selectedSessionId));
|
||||
} catch (error) {
|
||||
console.error('Takeover failed:', error);
|
||||
alert(t('bots.sessionMonitor.takeoverFailed'));
|
||||
} finally {
|
||||
setTakeoverLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRelease = async () => {
|
||||
if (!selectedSessionId) return;
|
||||
if (!confirm(t('bots.sessionMonitor.releaseConfirm'))) return;
|
||||
|
||||
setTakeoverLoading(true);
|
||||
try {
|
||||
await httpClient.releaseSession(selectedSessionId);
|
||||
setIsTakenOver(false);
|
||||
setTakenOverSessions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(selectedSessionId);
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Release failed:', error);
|
||||
alert(t('bots.sessionMonitor.releaseFailed'));
|
||||
} finally {
|
||||
setTakeoverLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!selectedSessionId || !operatorMessage.trim()) return;
|
||||
|
||||
setSendingMessage(true);
|
||||
try {
|
||||
await httpClient.sendTakeoverMessage(
|
||||
selectedSessionId,
|
||||
operatorMessage.trim(),
|
||||
);
|
||||
setOperatorMessage('');
|
||||
// Reload messages to show the sent one
|
||||
await loadMessages(selectedSessionId);
|
||||
} catch (error) {
|
||||
console.error('Send message failed:', error);
|
||||
alert(t('bots.sessionMonitor.sendFailed'));
|
||||
} finally {
|
||||
setSendingMessage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessageKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const parseMessageChain = (content: string): MessageChainComponent[] => {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
@@ -170,11 +299,16 @@ const BotSessionMonitor = forwardRef<
|
||||
};
|
||||
|
||||
const isUserMessage = (msg: SessionMessage): boolean => {
|
||||
if (msg.role === 'operator') return false;
|
||||
if (msg.role === 'assistant') return false;
|
||||
if (msg.role === 'user') return true;
|
||||
return !msg.runner_name;
|
||||
};
|
||||
|
||||
const isOperatorMessage = (msg: SessionMessage): boolean => {
|
||||
return msg.role === 'operator';
|
||||
};
|
||||
|
||||
const renderMessageComponent = (
|
||||
component: MessageChainComponent,
|
||||
index: number,
|
||||
@@ -240,7 +374,7 @@ const BotSessionMonitor = forwardRef<
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 text-muted-foreground text-xs"
|
||||
>
|
||||
🎙 [Voice]
|
||||
[Voice]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -274,7 +408,7 @@ const BotSessionMonitor = forwardRef<
|
||||
const file = component as MessageChainComponent & { name?: string };
|
||||
return (
|
||||
<span key={index} className="text-muted-foreground text-xs">
|
||||
📎 {file.name || 'File'}
|
||||
[{file.name || 'File'}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -334,6 +468,22 @@ const BotSessionMonitor = forwardRef<
|
||||
(s) => s.session_id === selectedSessionId,
|
||||
);
|
||||
|
||||
const getMessageRoleLabel = (msg: SessionMessage): string => {
|
||||
if (isOperatorMessage(msg)) {
|
||||
return t('bots.sessionMonitor.operatorMessage', {
|
||||
defaultValue: 'Operator',
|
||||
});
|
||||
}
|
||||
if (isUserMessage(msg)) {
|
||||
return t('bots.sessionMonitor.userMessage', {
|
||||
defaultValue: 'User',
|
||||
});
|
||||
}
|
||||
return t('bots.sessionMonitor.botMessage', {
|
||||
defaultValue: 'Assistant',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
||||
{/* Left Panel: Session List */}
|
||||
@@ -352,6 +502,9 @@ const BotSessionMonitor = forwardRef<
|
||||
<div className="p-1.5">
|
||||
{sessions.map((session) => {
|
||||
const isSelected = selectedSessionId === session.session_id;
|
||||
const sessionTakenOver = takenOverSessions.has(
|
||||
session.session_id,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={session.session_id}
|
||||
@@ -388,12 +541,16 @@ const BotSessionMonitor = forwardRef<
|
||||
{abbreviateId(session.user_id)}
|
||||
</span>
|
||||
)}
|
||||
{session.is_active && (
|
||||
{sessionTakenOver && (
|
||||
<span className="flex items-center gap-0.5 text-orange-600 dark:text-orange-400">
|
||||
<UserCheck className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
{session.is_active && !sessionTakenOver && (
|
||||
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||
</span>
|
||||
)}
|
||||
<span className="truncate">{session.pipeline_name}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
@@ -413,56 +570,92 @@ const BotSessionMonitor = forwardRef<
|
||||
<>
|
||||
{/* Chat Header */}
|
||||
<div className="px-4 py-2.5 border-b shrink-0">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{selectedSession?.user_name ||
|
||||
selectedSession?.user_id ||
|
||||
selectedSessionId.slice(0, 20)}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{selectedSession?.user_name ||
|
||||
selectedSession?.user_id ||
|
||||
selectedSessionId.slice(0, 20)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
{parseSessionType(selectedSessionId) && (
|
||||
<span>{parseSessionType(selectedSessionId)}</span>
|
||||
)}
|
||||
{selectedSession?.platform && (
|
||||
<>
|
||||
{parseSessionType(selectedSessionId) && <span>·</span>}
|
||||
<span>{selectedSession.platform}</span>
|
||||
</>
|
||||
)}
|
||||
{selectedSession?.user_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="font-mono">
|
||||
{selectedSession.user_id}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyUserId(selectedSession.user_id!)}
|
||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copiedUserId ? (
|
||||
<Check className="w-3 h-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{isTakenOver ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-1 text-orange-600 dark:text-orange-400">
|
||||
<UserCheck className="w-3 h-3" />
|
||||
{t('bots.sessionMonitor.takenOver', {
|
||||
defaultValue: 'Taken Over',
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
selectedSession?.is_active && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||
Active
|
||||
</span>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
{parseSessionType(selectedSessionId) && (
|
||||
<span>{parseSessionType(selectedSessionId)}</span>
|
||||
)}
|
||||
{selectedSession?.platform && (
|
||||
<>
|
||||
{parseSessionType(selectedSessionId) && <span>·</span>}
|
||||
<span>{selectedSession.platform}</span>
|
||||
</>
|
||||
)}
|
||||
{selectedSession?.user_id && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="font-mono">
|
||||
{selectedSession.user_id}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => copyUserId(selectedSession.user_id!)}
|
||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={t('common.copy')}
|
||||
>
|
||||
{copiedUserId ? (
|
||||
<Check className="w-3 h-3 text-green-600" />
|
||||
) : (
|
||||
<Copy className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{selectedSession?.pipeline_name && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span>{selectedSession.pipeline_name}</span>
|
||||
</>
|
||||
)}
|
||||
{selectedSession?.is_active && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||
Active
|
||||
</span>
|
||||
</>
|
||||
{/* Takeover / Release button */}
|
||||
<div className="flex-shrink-0">
|
||||
{isTakenOver ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRelease}
|
||||
disabled={takeoverLoading}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<UserCheck className="w-3.5 h-3.5" />
|
||||
{t('bots.sessionMonitor.releaseBtn', {
|
||||
defaultValue: 'Release',
|
||||
})}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTakeover}
|
||||
disabled={takeoverLoading}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md bg-primary/10 text-primary hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<UserCheck className="w-3.5 h-3.5" />
|
||||
{t('bots.sessionMonitor.takeoverBtn', {
|
||||
defaultValue: 'Take Over',
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -485,6 +678,10 @@ const BotSessionMonitor = forwardRef<
|
||||
) : (
|
||||
messages.map((msg) => {
|
||||
const isUser = isUserMessage(msg);
|
||||
const isOperator = isOperatorMessage(msg);
|
||||
const isDiscarded =
|
||||
msg.status === 'discarded' ||
|
||||
msg.pipeline_id === PIPELINE_DISCARD;
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
@@ -498,34 +695,59 @@ const BotSessionMonitor = forwardRef<
|
||||
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
|
||||
isUser
|
||||
? 'bg-primary/10 rounded-br-sm'
|
||||
: 'bg-muted rounded-bl-sm',
|
||||
: isOperator
|
||||
? 'bg-orange-100/80 dark:bg-orange-900/30 rounded-bl-sm'
|
||||
: 'bg-muted rounded-bl-sm',
|
||||
msg.status === 'error' && 'ring-1 ring-red-400/50',
|
||||
isDiscarded && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{renderMessageContent(msg)}
|
||||
{/* Role label + timestamp */}
|
||||
{/* Role label + pipeline + timestamp */}
|
||||
<div
|
||||
className={cn(
|
||||
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{isUser
|
||||
? t('bots.sessionMonitor.userMessage', {
|
||||
defaultValue: 'User',
|
||||
})
|
||||
: t('bots.sessionMonitor.botMessage', {
|
||||
defaultValue: 'Assistant',
|
||||
})}
|
||||
<span
|
||||
className={cn(
|
||||
isOperator &&
|
||||
'text-orange-600 dark:text-orange-400 font-medium',
|
||||
)}
|
||||
>
|
||||
{getMessageRoleLabel(msg)}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{formatTime(msg.timestamp)}
|
||||
</span>
|
||||
{isDiscarded ? (
|
||||
<span className="inline-flex items-center gap-0.5 text-destructive">
|
||||
<Ban className="w-3 h-3" />
|
||||
{t('bots.sessionMonitor.discarded', {
|
||||
defaultValue: 'Discarded',
|
||||
})}
|
||||
</span>
|
||||
) : msg.pipeline_name &&
|
||||
msg.pipeline_name !== 'Human Takeover' ? (
|
||||
<span className="inline-flex items-center gap-0.5 opacity-70">
|
||||
<Workflow className="w-3 h-3" />
|
||||
{msg.pipeline_name}
|
||||
</span>
|
||||
) : null}
|
||||
{isOperator && (
|
||||
<span className="inline-flex items-center gap-0.5 text-orange-600/70 dark:text-orange-400/70">
|
||||
<UserCheck className="w-3 h-3" />
|
||||
{t('bots.sessionMonitor.humanTakeover', {
|
||||
defaultValue: 'Human Takeover',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{msg.status === 'error' && (
|
||||
<span className="text-red-500">error</span>
|
||||
)}
|
||||
{msg.runner_name && (
|
||||
<span className="opacity-70">
|
||||
<span className="inline-flex items-center gap-0.5 opacity-70">
|
||||
<Bot className="w-3 h-3" />
|
||||
{msg.runner_name}
|
||||
</span>
|
||||
)}
|
||||
@@ -537,6 +759,33 @@ const BotSessionMonitor = forwardRef<
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Operator Message Input (only shown when session is taken over) */}
|
||||
{isTakenOver && (
|
||||
<div className="px-4 py-3 border-t shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={operatorMessage}
|
||||
onChange={(e) => setOperatorMessage(e.target.value)}
|
||||
onKeyDown={handleMessageKeyDown}
|
||||
placeholder={t('bots.sessionMonitor.sendMessage', {
|
||||
defaultValue: 'Send message as operator...',
|
||||
})}
|
||||
disabled={sendingMessage}
|
||||
className="flex-1 h-9 px-3 rounded-md border bg-background text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendMessage}
|
||||
disabled={sendingMessage || !operatorMessage.trim()}
|
||||
className="inline-flex items-center justify-center h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import BotDetailContent from './BotDetailContent';
|
||||
|
||||
export default function BotConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
if (detailId) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
||||
import { useRouter, usePathname, useSearchParams } from 'next/navigation';
|
||||
import { useNavigate, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -67,9 +65,10 @@ export default function ApiIntegrationDialog({
|
||||
onOpenChange,
|
||||
}: ApiIntegrationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
const [searchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState('apikeys');
|
||||
const [apiKeys, setApiKeys] = useState<ApiKey[]>([]);
|
||||
const [webhooks, setWebhooks] = useState<Webhook[]>([]);
|
||||
@@ -94,7 +93,9 @@ export default function ApiIntegrationDialog({
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showApiIntegrationSettings');
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
@@ -108,7 +109,7 @@ export default function ApiIntegrationDialog({
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
router.replace(newUrl, { scroll: false });
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
@@ -11,10 +11,14 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Globe } from 'lucide-react';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
|
||||
/**
|
||||
* Resolve the value referenced by a `show_if.field` string.
|
||||
@@ -40,6 +44,106 @@ function resolveShowIfValue(
|
||||
return externalDependentValues?.[field];
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only component for webhook URL fields.
|
||||
* Rendered outside of react-hook-form binding since the value is
|
||||
* read-only and comes from systemContext, not user input.
|
||||
*/
|
||||
function WebhookUrlField({
|
||||
label,
|
||||
description,
|
||||
url,
|
||||
extraUrl,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
url: string;
|
||||
extraUrl?: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [extraCopied, setExtraCopied] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCopy = (text: string, setter: (v: boolean) => void) => {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setter(true);
|
||||
setTimeout(() => setter(false), 2000);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormItem>
|
||||
<FormLabel>{label}</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={url}
|
||||
readOnly
|
||||
className="flex-1 bg-muted"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(url, setCopied)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
{extraUrl && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
value={extraUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-muted"
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleCopy(extraUrl, setExtraCopied)}
|
||||
>
|
||||
{extraCopied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{systemInfo.edition === 'community' && (
|
||||
<div className="flex items-start gap-2.5 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 mt-1 max-w-2xl">
|
||||
<Globe className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{t('bots.webhookSaasHint')}{' '}
|
||||
<a
|
||||
href="https://space.langbot.app/cloud?utm_source=local_webui&utm_medium=webhook_alert&utm_campaign=saas_conversion"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary underline-offset-4 hover:underline font-medium"
|
||||
>
|
||||
{t('bots.webhookSaasLink')}
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</FormItem>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DynamicFormComponent({
|
||||
itemConfigList,
|
||||
onSubmit,
|
||||
@@ -99,9 +203,16 @@ export default function DynamicFormComponent({
|
||||
return value;
|
||||
};
|
||||
|
||||
// Filter out display-only field types (e.g. webhook-url) that should not
|
||||
// participate in form state, validation, or value emission.
|
||||
const editableItems = useMemo(
|
||||
() => itemConfigList.filter((item) => item.type !== 'webhook-url'),
|
||||
[itemConfigList],
|
||||
);
|
||||
|
||||
// 根据 itemConfigList 动态生成 zod schema
|
||||
const formSchema = z.object(
|
||||
itemConfigList.reduce(
|
||||
editableItems.reduce(
|
||||
(acc, item) => {
|
||||
let fieldSchema;
|
||||
switch (item.type) {
|
||||
@@ -138,6 +249,9 @@ export default function DynamicFormComponent({
|
||||
case 'bot-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'tools-selector':
|
||||
fieldSchema = z.array(z.string());
|
||||
break;
|
||||
case 'model-fallback-selector':
|
||||
fieldSchema = z.object({
|
||||
primary: z.string(),
|
||||
@@ -179,7 +293,7 @@ export default function DynamicFormComponent({
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: itemConfigList.reduce((acc, item) => {
|
||||
defaultValues: editableItems.reduce((acc, item) => {
|
||||
// 优先使用 initialValues,如果没有则使用默认值
|
||||
const rawValue = initialValues?.[item.name] ?? item.default;
|
||||
return {
|
||||
@@ -207,7 +321,7 @@ export default function DynamicFormComponent({
|
||||
|
||||
if (initialValues && hasRealChange) {
|
||||
// 合并默认值和初始值
|
||||
const mergedValues = itemConfigList.reduce(
|
||||
const mergedValues = editableItems.reduce(
|
||||
(acc, item) => {
|
||||
const rawValue = initialValues[item.name] ?? item.default;
|
||||
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
|
||||
@@ -222,7 +336,7 @@ export default function DynamicFormComponent({
|
||||
|
||||
previousInitialValues.current = initialValues;
|
||||
}
|
||||
}, [initialValues, form, itemConfigList]);
|
||||
}, [initialValues, form, editableItems]);
|
||||
|
||||
// Get reactive form values for conditional rendering
|
||||
const watchedValues = form.watch();
|
||||
@@ -238,7 +352,7 @@ export default function DynamicFormComponent({
|
||||
// even if the user saves without modifying any field.
|
||||
// form.watch(callback) only fires on subsequent changes, not on mount.
|
||||
const formValues = form.getValues();
|
||||
const initialFinalValues = itemConfigList.reduce(
|
||||
const initialFinalValues = editableItems.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
@@ -258,7 +372,7 @@ export default function DynamicFormComponent({
|
||||
|
||||
const subscription = form.watch(() => {
|
||||
const formValues = form.getValues();
|
||||
const finalValues = itemConfigList.reduce(
|
||||
const finalValues = editableItems.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
@@ -269,7 +383,7 @@ export default function DynamicFormComponent({
|
||||
previousInitialValues.current = finalValues as Record<string, object>;
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, itemConfigList]);
|
||||
}, [form, editableItems]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -307,6 +421,29 @@ export default function DynamicFormComponent({
|
||||
// All fields are disabled when editing (creation_settings are immutable)
|
||||
const isFieldDisabled = !!isEditing;
|
||||
|
||||
// Webhook URL fields are display-only; render outside of form binding
|
||||
if (config.type === 'webhook-url') {
|
||||
const webhookUrl = (systemContext?.webhook_url as string) || '';
|
||||
const extraWebhookUrl =
|
||||
(systemContext?.extra_webhook_url as string) || '';
|
||||
|
||||
if (!webhookUrl) return null;
|
||||
|
||||
return (
|
||||
<WebhookUrlField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
url={webhookUrl}
|
||||
extraUrl={extraWebhookUrl || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean fields use a special inline layout
|
||||
if (config.type === 'boolean') {
|
||||
return (
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
Bot,
|
||||
KnowledgeBase,
|
||||
EmbeddingModel,
|
||||
PluginTool,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -37,7 +38,29 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Plus, X, Eye, Wrench, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Plus,
|
||||
X,
|
||||
Eye,
|
||||
Wrench,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
Info,
|
||||
Settings,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||
|
||||
export default function DynamicFormItemComponent({
|
||||
config,
|
||||
@@ -53,10 +76,34 @@ export default function DynamicFormItemComponent({
|
||||
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
|
||||
const [bots, setBots] = useState<Bot[]>([]);
|
||||
const [tools, setTools] = useState<PluginTool[]>([]);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
||||
const [tempSelectedKBIds, setTempSelectedKBIds] = useState<string[]>([]);
|
||||
const [toolsDialogOpen, setToolsDialogOpen] = useState(false);
|
||||
const [tempSelectedToolNames, setTempSelectedToolNames] = useState<string[]>(
|
||||
[],
|
||||
);
|
||||
const { t } = useTranslation();
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
|
||||
const fetchLlmModels = () => {
|
||||
httpClient
|
||||
.getProviderLLMModels()
|
||||
.then((resp) => {
|
||||
setLlmModels(resp.models);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('models.getModelListError') + err.msg);
|
||||
});
|
||||
};
|
||||
|
||||
const handleModelsDialogChange = (open: boolean) => {
|
||||
setModelsDialogOpen(open);
|
||||
if (!open) {
|
||||
fetchLlmModels();
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (file: File): Promise<IFileConfig | null> => {
|
||||
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
@@ -88,26 +135,35 @@ export default function DynamicFormItemComponent({
|
||||
}
|
||||
};
|
||||
|
||||
// Whether to show Space login CTA in model selectors
|
||||
const showSpaceLoginCTA =
|
||||
!systemInfo.disable_models_service && userInfo?.account_type !== 'space';
|
||||
|
||||
const handleSpaceLogin = () => {
|
||||
try {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
toast.error(t('common.error'));
|
||||
return;
|
||||
}
|
||||
const currentOrigin = window.location.origin;
|
||||
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
||||
httpClient
|
||||
.getSpaceAuthorizeUrl(redirectUri, token)
|
||||
.then((response) => {
|
||||
window.location.href = response.authorize_url;
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
});
|
||||
} catch {
|
||||
toast.error(t('common.spaceLoginFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (config.type === DynamicFormItemType.LLM_MODEL_SELECTOR) {
|
||||
httpClient
|
||||
.getProviderLLMModels()
|
||||
.then((resp) => {
|
||||
let models = resp.models;
|
||||
// Filter out space-chat-completions models when not logged in with space account or when models service is disabled
|
||||
if (
|
||||
systemInfo.disable_models_service ||
|
||||
userInfo?.account_type !== 'space'
|
||||
) {
|
||||
models = models.filter(
|
||||
(m) => m.provider?.requester !== 'space-chat-completions',
|
||||
);
|
||||
}
|
||||
setLlmModels(models);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('models.getModelListError') + err.msg);
|
||||
});
|
||||
fetchLlmModels();
|
||||
}
|
||||
}, [config.type]);
|
||||
|
||||
@@ -126,23 +182,7 @@ export default function DynamicFormItemComponent({
|
||||
|
||||
useEffect(() => {
|
||||
if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {
|
||||
httpClient
|
||||
.getProviderLLMModels()
|
||||
.then((resp) => {
|
||||
let models = resp.models;
|
||||
if (
|
||||
systemInfo.disable_models_service ||
|
||||
userInfo?.account_type !== 'space'
|
||||
) {
|
||||
models = models.filter(
|
||||
(m) => m.provider?.requester !== 'space-chat-completions',
|
||||
);
|
||||
}
|
||||
setLlmModels(models);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error('Failed to get LLM model list: ' + err.msg);
|
||||
});
|
||||
fetchLlmModels();
|
||||
}
|
||||
}, [config.type]);
|
||||
|
||||
@@ -175,6 +215,21 @@ export default function DynamicFormItemComponent({
|
||||
}
|
||||
}, [config.type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (config.type === DynamicFormItemType.TOOLS_SELECTOR) {
|
||||
httpClient
|
||||
.getTools()
|
||||
.then((resp) => {
|
||||
setTools(resp.tools);
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(
|
||||
t('tools.getToolListError', 'Failed to get tools: ') + err.msg,
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [config.type]);
|
||||
|
||||
switch (config.type) {
|
||||
case DynamicFormItemType.INT:
|
||||
case DynamicFormItemType.FLOAT:
|
||||
@@ -188,6 +243,40 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.STRING:
|
||||
if (config.options && config.options.length > 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 max-w-md">
|
||||
<Input className="flex-1" {...field} />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="h-9 w-9 shrink-0 text-muted-foreground"
|
||||
>
|
||||
<ChevronDown className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{config.options.map((option) => (
|
||||
<DropdownMenuItem
|
||||
key={option.name}
|
||||
onClick={() => field.onChange(option.name)}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span>{extractI18nObject(option.label)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{option.name}
|
||||
</span>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Input className="max-w-md" {...field} />;
|
||||
|
||||
case DynamicFormItemType.TEXT:
|
||||
@@ -249,7 +338,11 @@ export default function DynamicFormItemComponent({
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{config.options?.map((option) => (
|
||||
<SelectItem key={option.name} value={option.name}>
|
||||
<SelectItem
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
description={option.name}
|
||||
>
|
||||
{extractI18nObject(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -259,8 +352,16 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.LLM_MODEL_SELECTOR:
|
||||
// Group models by provider
|
||||
const groupedModels = llmModels.reduce(
|
||||
// Separate space models from regular models
|
||||
const spaceModels = llmModels.filter(
|
||||
(m) => m.provider?.requester === 'space-chat-completions',
|
||||
);
|
||||
const regularModels = llmModels.filter(
|
||||
(m) => m.provider?.requester !== 'space-chat-completions',
|
||||
);
|
||||
|
||||
// Group regular models by provider
|
||||
const groupedModels = regularModels.reduce(
|
||||
(acc, model) => {
|
||||
const providerName =
|
||||
model.provider?.name || model.provider?.requester || 'Unknown';
|
||||
@@ -271,33 +372,180 @@ export default function DynamicFormItemComponent({
|
||||
{} as Record<string, LLMModel[]>,
|
||||
);
|
||||
|
||||
// Group space models by provider (for logged-in users)
|
||||
const groupedSpaceModels = spaceModels.reduce(
|
||||
(acc, model) => {
|
||||
const providerName =
|
||||
model.provider?.name || model.provider?.requester || 'Unknown';
|
||||
if (!acc[providerName]) acc[providerName] = [];
|
||||
acc[providerName].push(model);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, LLMModel[]>,
|
||||
);
|
||||
|
||||
// Hardcoded preview model names for CTA when no space models are synced
|
||||
const previewModelNames = [
|
||||
'gpt-4o',
|
||||
'claude-sonnet-4-20250514',
|
||||
'deepseek-chat',
|
||||
'gemini-2.5-flash',
|
||||
'qwen-plus',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-md">
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('models.selectModel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedModels).map(([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
{model.abilities?.includes('vision') && (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{model.abilities?.includes('func_call') && (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
<div className="max-w-md flex items-center gap-1.5">
|
||||
<div className="flex-1">
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('models.selectModel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedModels).map(([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
{model.abilities?.includes('vision') && (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{model.abilities?.includes('func_call') && (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
{/* Space models section */}
|
||||
{showSpaceLoginCTA ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||
{t('models.langbotModels')}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
{t('models.spaceTrialTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectLabel>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Preview models (first 3 visible, rest blurred) */}
|
||||
{(spaceModels.length > 0
|
||||
? spaceModels.map((m) => m.name)
|
||||
: previewModelNames
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
{/* Blurred remaining models with login overlay */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="select-none overflow-hidden"
|
||||
style={{ maxHeight: '3rem' }}
|
||||
>
|
||||
{(spaceModels.length > 0
|
||||
? spaceModels.map((m) => m.name)
|
||||
: previewModelNames
|
||||
)
|
||||
.slice(3)
|
||||
.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Login overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSpaceLogin();
|
||||
}}
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('models.unlockModels')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectGroup>
|
||||
) : !systemInfo.disable_models_service ? (
|
||||
// User is logged into Space — show space models normally
|
||||
Object.entries(groupedSpaceModels).map(
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||
{providerName}
|
||||
</span>
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
{model.abilities?.includes('vision') && (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{model.abilities?.includes('func_call') && (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
),
|
||||
)
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => setModelsDialogOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{t('models.title')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<ModelsDialog
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -338,8 +586,16 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
|
||||
// Group models by provider
|
||||
const groupedModelsForFallback = llmModels.reduce(
|
||||
// Separate space models from regular models
|
||||
const fbSpaceModels = llmModels.filter(
|
||||
(m) => m.provider?.requester === 'space-chat-completions',
|
||||
);
|
||||
const fbRegularModels = llmModels.filter(
|
||||
(m) => m.provider?.requester !== 'space-chat-completions',
|
||||
);
|
||||
|
||||
// Group regular models by provider
|
||||
const groupedModelsForFallback = fbRegularModels.reduce(
|
||||
(acc, model) => {
|
||||
const providerName =
|
||||
model.provider?.name || model.provider?.requester || 'Unknown';
|
||||
@@ -350,6 +606,27 @@ export default function DynamicFormItemComponent({
|
||||
{} as Record<string, LLMModel[]>,
|
||||
);
|
||||
|
||||
// Group space models by provider (for logged-in users)
|
||||
const fbGroupedSpaceModels = fbSpaceModels.reduce(
|
||||
(acc, model) => {
|
||||
const providerName =
|
||||
model.provider?.name || model.provider?.requester || 'Unknown';
|
||||
if (!acc[providerName]) acc[providerName] = [];
|
||||
acc[providerName].push(model);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, LLMModel[]>,
|
||||
);
|
||||
|
||||
// Hardcoded preview model names for CTA
|
||||
const fbPreviewModelNames = [
|
||||
'gpt-4o',
|
||||
'claude-sonnet-4-20250514',
|
||||
'deepseek-chat',
|
||||
'gemini-2.5-flash',
|
||||
'qwen-plus',
|
||||
];
|
||||
|
||||
const rawModelValue = field.value;
|
||||
const modelValue: { primary: string; fallbacks: string[] } =
|
||||
rawModelValue != null &&
|
||||
@@ -406,6 +683,112 @@ export default function DynamicFormItemComponent({
|
||||
</SelectGroup>
|
||||
),
|
||||
)}
|
||||
{/* Space models section */}
|
||||
{showSpaceLoginCTA ? (
|
||||
<SelectGroup>
|
||||
<SelectLabel>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||
{t('models.langbotModels')}
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-[240px]">
|
||||
{t('models.spaceTrialTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</span>
|
||||
</SelectLabel>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Preview models (first 3 visible, rest blurred) */}
|
||||
{(fbSpaceModels.length > 0
|
||||
? fbSpaceModels.map((m) => m.name)
|
||||
: fbPreviewModelNames
|
||||
)
|
||||
.slice(0, 3)
|
||||
.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
{/* Blurred remaining models with login overlay */}
|
||||
<div className="relative">
|
||||
<div
|
||||
className="select-none overflow-hidden"
|
||||
style={{ maxHeight: '3rem' }}
|
||||
>
|
||||
{(fbSpaceModels.length > 0
|
||||
? fbSpaceModels.map((m) => m.name)
|
||||
: fbPreviewModelNames
|
||||
)
|
||||
.slice(3)
|
||||
.map((name) => (
|
||||
<div
|
||||
key={name}
|
||||
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Login overlay */}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSpaceLogin();
|
||||
}}
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('models.unlockModels')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SelectGroup>
|
||||
) : !systemInfo.disable_models_service ? (
|
||||
// User is logged into Space — show space models normally
|
||||
Object.entries(fbGroupedSpaceModels).map(
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||
{providerName}
|
||||
</span>
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{model.name}
|
||||
{model.abilities?.includes('vision') && (
|
||||
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
{model.abilities?.includes('func_call') && (
|
||||
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
),
|
||||
)
|
||||
) : null}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
@@ -448,11 +831,35 @@ export default function DynamicFormItemComponent({
|
||||
<p className="text-xs text-muted-foreground mb-1">
|
||||
{t('models.fallback.primary')}
|
||||
</p>
|
||||
{renderModelSelect(
|
||||
modelValue.primary,
|
||||
(val) => updateValue({ primary: val }),
|
||||
t('models.selectModel'),
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="flex-1">
|
||||
{renderModelSelect(
|
||||
modelValue.primary,
|
||||
(val) => updateValue({ primary: val }),
|
||||
t('models.selectModel'),
|
||||
)}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 shrink-0"
|
||||
onClick={() => setModelsDialogOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">
|
||||
{t('models.title')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<ModelsDialog
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fallback models */}
|
||||
@@ -775,6 +1182,139 @@ export default function DynamicFormItemComponent({
|
||||
</Select>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.TOOLS_SELECTOR:
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
{field.value && field.value.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{field.value.map((toolName: string) => {
|
||||
const currentTool = tools.find(
|
||||
(tool) => tool.name === toolName,
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toolName}
|
||||
className="flex items-center justify-between rounded-lg border p-3 hover:bg-accent"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium">{toolName}</div>
|
||||
{currentTool?.human_desc && (
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{currentTool.human_desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
const newValue = field.value.filter(
|
||||
(name: string) => name !== toolName,
|
||||
);
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('tools.noToolSelected', 'No tools selected')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTempSelectedToolNames(field.value || []);
|
||||
setToolsDialogOpen(true);
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('tools.addTool', 'Add Tool')}
|
||||
</Button>
|
||||
|
||||
<Dialog open={toolsDialogOpen} onOpenChange={setToolsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t('tools.selectTools', 'Select Tools')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto space-y-2 pr-2">
|
||||
{tools.map((tool) => {
|
||||
const isSelected = tempSelectedToolNames.includes(tool.name);
|
||||
return (
|
||||
<div
|
||||
key={tool.name}
|
||||
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
||||
onClick={() => {
|
||||
setTempSelectedToolNames((prev) =>
|
||||
prev.includes(tool.name)
|
||||
? prev.filter((name) => name !== tool.name)
|
||||
: [...prev, tool.name],
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
aria-label={`Select ${tool.name}`}
|
||||
/>
|
||||
<Wrench className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{tool.name}</div>
|
||||
{tool.human_desc && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{tool.human_desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{tools.length === 0 && (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('tools.noToolsAvailable', 'No tools available')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setToolsDialogOpen(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
field.onChange(tempSelectedToolNames);
|
||||
setToolsDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.PROMPT_EDITOR: {
|
||||
// Guard: field.value may be undefined when the form resets or
|
||||
// initialValues haven't propagated yet. Fall back to a default
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user