mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-03 04:24:36 +00:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8aedd02b3 | ||
|
|
ea638cab80 | ||
|
|
7129dd536e | ||
|
|
1b1cc7769b | ||
|
|
44b8354dfd | ||
|
|
55ec9d11ae | ||
|
|
5b3d3801b5 | ||
|
|
9f1ea75d09 | ||
|
|
6e37aae636 | ||
|
|
921d12f596 | ||
|
|
6bf6deaefd | ||
|
|
1201949f2c | ||
|
|
1c419e3591 | ||
|
|
b0a9be77b0 | ||
|
|
e02ade5a30 | ||
|
|
1a51ba8e7e | ||
|
|
e7b22d6ebf | ||
|
|
dddfa8ac79 | ||
|
|
99e2976826 | ||
|
|
71e44f0e54 | ||
|
|
4c904c2375 | ||
|
|
498d030da9 | ||
|
|
c111bf1714 | ||
|
|
6570f276d2 | ||
|
|
42e1e038bd | ||
|
|
d0e54a45c7 | ||
|
|
23fa47b07e | ||
|
|
4902c1d3b2 | ||
|
|
a6f96e5209 | ||
|
|
37c41bcfe4 | ||
|
|
9e223949a7 | ||
|
|
267bd72c63 | ||
|
|
af0d00e5e9 | ||
|
|
244e16c491 | ||
|
|
cad259fe39 | ||
|
|
bc3199bf29 | ||
|
|
127dc455c3 | ||
|
|
e8dc6fde53 | ||
|
|
4a97895dea | ||
|
|
3c0495fc51 | ||
|
|
dfd25deb68 |
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:
|
||||
|
||||
@@ -9,16 +9,14 @@ repos:
|
||||
# Run the formatter of backend.
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-prettier
|
||||
rev: v3.1.0
|
||||
hooks:
|
||||
- id: prettier
|
||||
types_or: [javascript, jsx, ts, tsx, css, scss]
|
||||
additional_dependencies:
|
||||
- prettier@3.1.0
|
||||
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: prettier
|
||||
name: prettier
|
||||
entry: npx --prefix web prettier --write --ignore-unknown
|
||||
language: system
|
||||
types_or: [javascript, jsx, ts, tsx, css, scss]
|
||||
|
||||
- id: lint-staged
|
||||
name: lint-staged
|
||||
entry: cd web && pnpm lint-staged
|
||||
|
||||
12
README.md
12
README.md
@@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本
|
||||
[](https://github.com/langbot-app/LangBot/stargazers)
|
||||
|
||||
<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.6",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.9.4'
|
||||
__version__ = '4.9.5'
|
||||
|
||||
@@ -272,15 +272,30 @@ class DingTalkClient:
|
||||
|
||||
message_data['Type'] = 'audio'
|
||||
elif incoming_message.message_type == 'file':
|
||||
down_list = incoming_message.get_down_list()
|
||||
if len(down_list) >= 2:
|
||||
message_data['File'] = await self.get_file_url(down_list[0])
|
||||
message_data['Name'] = down_list[1]
|
||||
# 获取原始数据字典并提取嵌套的文件信息
|
||||
raw_data = incoming_message.to_dict()
|
||||
file_info = raw_data.get('content', {})
|
||||
|
||||
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
|
||||
if isinstance(file_info, str):
|
||||
try:
|
||||
file_info = json.loads(file_info)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
file_info = {}
|
||||
|
||||
download_code = file_info.get('downloadCode')
|
||||
file_name = file_info.get('fileName')
|
||||
|
||||
if download_code and file_name:
|
||||
# 转换 downloadCode 为可下载的真实 URL
|
||||
message_data['File'] = await self.get_file_url(download_code)
|
||||
message_data['Name'] = file_name
|
||||
else:
|
||||
if self.logger:
|
||||
await self.logger.error(f'get_down_list() returned fewer than 2 elements: {down_list}')
|
||||
await self.logger.error(f'Failed to extract file info from message content: {file_info}')
|
||||
message_data['File'] = None
|
||||
message_data['Name'] = None
|
||||
|
||||
message_data['Type'] = 'file'
|
||||
|
||||
copy_message_data = message_data.copy()
|
||||
|
||||
@@ -6,7 +6,8 @@ import traceback
|
||||
import uuid
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Optional
|
||||
import re
|
||||
from typing import Any, Callable, Optional, Tuple
|
||||
from urllib.parse import unquote
|
||||
|
||||
import httpx
|
||||
@@ -63,6 +64,9 @@ class StreamSession:
|
||||
# 缓存最近一次片段,处理重试或超时兜底
|
||||
last_chunk: Optional[StreamChunk] = None
|
||||
|
||||
# 反馈 ID,用于接收用户点赞/点踩反馈
|
||||
feedback_id: Optional[str] = None
|
||||
|
||||
|
||||
class StreamSessionManager:
|
||||
"""管理 stream 会话的生命周期,并负责队列的生产消费。"""
|
||||
@@ -73,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:
|
||||
@@ -82,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]:
|
||||
"""根据企业微信回调创建或获取会话。
|
||||
|
||||
@@ -199,52 +230,139 @@ class StreamSessionManager:
|
||||
self._msg_index.pop(msg_id, None)
|
||||
|
||||
|
||||
async def download_encrypted_file(download_url: str, encoding_aes_key: str, logger: EventLogger) -> Optional[str]:
|
||||
"""Download an AES-encrypted file from WeChat Work and return as data URI.
|
||||
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
||||
"""Decrypt AES-256-CBC encrypted file data.
|
||||
|
||||
Aligned with the official WeCom AI Bot Python SDK (crypto_utils.py).
|
||||
|
||||
Args:
|
||||
download_url: The encrypted file download URL.
|
||||
encoding_aes_key: The AES key used for decryption (base64-encoded, without trailing '=').
|
||||
logger: Logger instance.
|
||||
encrypted_data: The raw encrypted bytes.
|
||||
aes_key_str: Base64-encoded AES key (may lack padding).
|
||||
|
||||
Returns:
|
||||
A data URI string (e.g. 'data:image/jpeg;base64,...') or None on failure.
|
||||
Decrypted bytes with PKCS#7 padding removed.
|
||||
"""
|
||||
if not download_url:
|
||||
return None
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(download_url)
|
||||
if response.status_code != 200:
|
||||
await logger.error(f'failed to get file: {response.text}')
|
||||
return None
|
||||
encrypted_bytes = response.content
|
||||
if not encrypted_data:
|
||||
raise ValueError('encrypted_data is empty')
|
||||
if not aes_key_str:
|
||||
raise ValueError('aes_key is empty')
|
||||
|
||||
aes_key = base64.b64decode(encoding_aes_key + '=')
|
||||
iv = aes_key[:16]
|
||||
# Python's base64.b64decode requires proper padding (length % 4 == 0).
|
||||
# Node.js Buffer.from tolerates missing '=', so we must pad manually.
|
||||
remainder = len(aes_key_str) % 4
|
||||
if remainder != 0:
|
||||
aes_key_str = aes_key_str + '=' * (4 - remainder)
|
||||
key = base64.b64decode(aes_key_str)
|
||||
|
||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
||||
decrypted = cipher.decrypt(encrypted_bytes)
|
||||
iv = key[:16]
|
||||
|
||||
cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
|
||||
# Ensure encrypted data is aligned to AES block size (16 bytes).
|
||||
# Node.js setAutoPadding(false) silently handles unaligned data,
|
||||
# but PyCryptodome will raise an error.
|
||||
block_size = 16
|
||||
data_remainder = len(encrypted_data) % block_size
|
||||
if data_remainder != 0:
|
||||
encrypted_data = encrypted_data + b'\x00' * (block_size - data_remainder)
|
||||
|
||||
decrypted = cipher.decrypt(encrypted_data)
|
||||
|
||||
# Remove PKCS#7 padding with validation
|
||||
if len(decrypted) == 0:
|
||||
raise ValueError('Decrypted data is empty')
|
||||
|
||||
pad_len = decrypted[-1]
|
||||
decrypted = decrypted[:-pad_len]
|
||||
if pad_len < 1 or pad_len > 32 or pad_len > len(decrypted):
|
||||
raise ValueError(f'Invalid PKCS#7 padding value: {pad_len}')
|
||||
|
||||
if decrypted.startswith(b'\xff\xd8'):
|
||||
# Verify all padding bytes are consistent
|
||||
for i in range(len(decrypted) - pad_len, len(decrypted)):
|
||||
if decrypted[i] != pad_len:
|
||||
raise ValueError('Invalid PKCS#7 padding: padding bytes mismatch')
|
||||
|
||||
return decrypted[: len(decrypted) - pad_len]
|
||||
|
||||
|
||||
def _extract_filename(content_disposition: str) -> Optional[str]:
|
||||
"""Extract filename from a Content-Disposition header value."""
|
||||
if not content_disposition:
|
||||
return None
|
||||
# RFC 5987: filename*=UTF-8''xxx
|
||||
utf8_match = re.search(r"filename\*=UTF-8''([^;\s]+)", content_disposition, re.IGNORECASE)
|
||||
if utf8_match:
|
||||
return unquote(utf8_match.group(1))
|
||||
# Standard: filename="xxx" or filename=xxx
|
||||
match = re.search(r'filename="?([^";\s]+)"?', content_disposition, re.IGNORECASE)
|
||||
if match:
|
||||
return unquote(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def _bytes_to_data_uri(data: bytes) -> str:
|
||||
"""Convert raw bytes to a data URI with auto-detected MIME type."""
|
||||
if data.startswith(b'\xff\xd8'):
|
||||
mime_type = 'image/jpeg'
|
||||
elif decrypted.startswith(b'\x89PNG'):
|
||||
elif data.startswith(b'\x89PNG'):
|
||||
mime_type = 'image/png'
|
||||
elif decrypted.startswith((b'GIF87a', b'GIF89a')):
|
||||
elif data.startswith((b'GIF87a', b'GIF89a')):
|
||||
mime_type = 'image/gif'
|
||||
elif decrypted.startswith(b'BM'):
|
||||
elif data.startswith(b'BM'):
|
||||
mime_type = 'image/bmp'
|
||||
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'):
|
||||
elif data.startswith(b'II*\x00') or data.startswith(b'MM\x00*'):
|
||||
mime_type = 'image/tiff'
|
||||
elif data[:4] == b'%PDF':
|
||||
mime_type = 'application/pdf'
|
||||
elif data[:4] == b'PK\x03\x04':
|
||||
mime_type = 'application/zip'
|
||||
else:
|
||||
mime_type = 'application/octet-stream'
|
||||
|
||||
base64_str = base64.b64encode(decrypted).decode('utf-8')
|
||||
base64_str = base64.b64encode(data).decode('utf-8')
|
||||
return f'data:{mime_type};base64,{base64_str}'
|
||||
|
||||
|
||||
async def download_encrypted_file(
|
||||
download_url: str, aes_key: str, logger: EventLogger
|
||||
) -> Tuple[Optional[bytes], Optional[str]]:
|
||||
"""Download an AES-encrypted file from WeChat Work and decrypt it.
|
||||
|
||||
Args:
|
||||
download_url: The encrypted file download URL.
|
||||
aes_key: The AES key for decryption (base64-encoded, per-message aeskey
|
||||
or platform EncodingAESKey).
|
||||
logger: Logger instance.
|
||||
|
||||
Returns:
|
||||
A tuple of (decrypted_bytes, filename) or (None, None) on failure.
|
||||
"""
|
||||
if not download_url:
|
||||
return None, None
|
||||
if not aes_key:
|
||||
await logger.error('download_encrypted_file: aes_key is empty, cannot decrypt')
|
||||
return None, None
|
||||
|
||||
filename: Optional[str] = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(download_url)
|
||||
if response.status_code != 200:
|
||||
await logger.error(f'Failed to download file (HTTP {response.status_code}): {response.text[:200]}')
|
||||
return None, None
|
||||
encrypted_bytes = response.content
|
||||
filename = _extract_filename(response.headers.get('content-disposition', ''))
|
||||
except Exception:
|
||||
await logger.error(f'Failed to download file: {traceback.format_exc()}')
|
||||
return None, None
|
||||
|
||||
try:
|
||||
decrypted = _decrypt_file(encrypted_bytes, aes_key)
|
||||
return decrypted, filename
|
||||
except Exception:
|
||||
await logger.error(f'Failed to decrypt file: {traceback.format_exc()}')
|
||||
return None, None
|
||||
|
||||
|
||||
async def parse_wecom_bot_message(
|
||||
msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger
|
||||
) -> dict[str, Any]:
|
||||
@@ -273,10 +391,22 @@ async def parse_wecom_bot_message(
|
||||
|
||||
max_inline_file_size = 5 * 1024 * 1024
|
||||
|
||||
async def _safe_download(url: str):
|
||||
async def _safe_download(url: str, per_msg_aeskey: str = '') -> Tuple[Optional[bytes], Optional[str]]:
|
||||
"""Download and decrypt a file, preferring per-message aeskey over platform key."""
|
||||
if not url:
|
||||
return None
|
||||
return await download_encrypted_file(url, encoding_aes_key, logger)
|
||||
return None, None
|
||||
key = per_msg_aeskey or encoding_aes_key
|
||||
if not key:
|
||||
await logger.warning('No AES key available for file decryption, skipping download')
|
||||
return None, None
|
||||
return await download_encrypted_file(url, key, logger)
|
||||
|
||||
async def _safe_download_as_data_uri(url: str, per_msg_aeskey: str = '') -> Optional[str]:
|
||||
"""Download, decrypt, and convert to data URI for backward compatibility."""
|
||||
data, _filename = await _safe_download(url, per_msg_aeskey)
|
||||
if data:
|
||||
return _bytes_to_data_uri(data)
|
||||
return None
|
||||
|
||||
if msg_type == 'text':
|
||||
message_data['content'] = msg_json.get('text', {}).get('content')
|
||||
@@ -285,14 +415,17 @@ async def parse_wecom_bot_message(
|
||||
'content', ''
|
||||
)
|
||||
elif msg_type == 'image':
|
||||
picurl = msg_json.get('image', {}).get('url', '')
|
||||
base64_data = await _safe_download(picurl)
|
||||
image_info = msg_json.get('image', {})
|
||||
picurl = image_info.get('url', '')
|
||||
per_msg_aeskey = image_info.get('aeskey', '')
|
||||
base64_data = await _safe_download_as_data_uri(picurl, per_msg_aeskey)
|
||||
if base64_data:
|
||||
message_data['picurl'] = base64_data
|
||||
message_data['images'] = [base64_data]
|
||||
elif msg_type == 'voice':
|
||||
voice_info = msg_json.get('voice', {}) or {}
|
||||
download_url = voice_info.get('url')
|
||||
per_msg_aeskey = voice_info.get('aeskey', '')
|
||||
message_data['voice'] = {
|
||||
'url': download_url,
|
||||
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||
@@ -302,12 +435,13 @@ 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(download_url)
|
||||
voice_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||
if voice_base64:
|
||||
message_data['voice']['base64'] = voice_base64
|
||||
elif msg_type == 'video':
|
||||
video_info = msg_json.get('video', {}) or {}
|
||||
download_url = video_info.get('url')
|
||||
per_msg_aeskey = video_info.get('aeskey', '')
|
||||
video_data = {
|
||||
'url': download_url,
|
||||
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||
@@ -316,13 +450,14 @@ async def parse_wecom_bot_message(
|
||||
'filename': video_info.get('filename') or video_info.get('name'),
|
||||
}
|
||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
video_base64 = await _safe_download(download_url)
|
||||
video_base64 = await _safe_download_as_data_uri(download_url, per_msg_aeskey)
|
||||
if video_base64:
|
||||
video_data['base64'] = video_base64
|
||||
message_data['video'] = video_data
|
||||
elif msg_type == 'file':
|
||||
file_info = msg_json.get('file', {}) or {}
|
||||
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||
per_msg_aeskey = file_info.get('aeskey', '')
|
||||
file_data = {
|
||||
'filename': file_info.get('filename') or file_info.get('name'),
|
||||
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||
@@ -332,9 +467,11 @@ async def parse_wecom_bot_message(
|
||||
'extra': file_info,
|
||||
}
|
||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
file_base64 = await _safe_download(download_url)
|
||||
if file_base64:
|
||||
file_data['base64'] = file_base64
|
||||
file_bytes, dl_filename = await _safe_download(download_url, per_msg_aeskey)
|
||||
if file_bytes:
|
||||
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||
if dl_filename and not file_data.get('filename'):
|
||||
file_data['filename'] = dl_filename
|
||||
message_data['file'] = file_data
|
||||
elif msg_type == 'link':
|
||||
message_data['link'] = msg_json.get('link', {})
|
||||
@@ -355,13 +492,16 @@ async def parse_wecom_bot_message(
|
||||
if item_type == 'text':
|
||||
texts.append(item.get('text', {}).get('content', ''))
|
||||
elif item_type == 'image':
|
||||
img_url = item.get('image', {}).get('url')
|
||||
base64_data = await _safe_download(img_url)
|
||||
img_info = item.get('image', {})
|
||||
img_url = img_info.get('url')
|
||||
img_aeskey = img_info.get('aeskey', '')
|
||||
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
|
||||
if base64_data:
|
||||
images.append(base64_data)
|
||||
elif item_type == 'file':
|
||||
file_info = item.get('file', {}) or {}
|
||||
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||
item_aeskey = file_info.get('aeskey', '')
|
||||
file_data = {
|
||||
'filename': file_info.get('filename') or file_info.get('name'),
|
||||
'filesize': file_info.get('filesize') or file_info.get('size'),
|
||||
@@ -371,13 +511,16 @@ async def parse_wecom_bot_message(
|
||||
'extra': file_info,
|
||||
}
|
||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
file_base64 = await _safe_download(download_url)
|
||||
if file_base64:
|
||||
file_data['base64'] = file_base64
|
||||
file_bytes, dl_filename = await _safe_download(download_url, item_aeskey)
|
||||
if file_bytes:
|
||||
file_data['base64'] = _bytes_to_data_uri(file_bytes)
|
||||
if dl_filename and not file_data.get('filename'):
|
||||
file_data['filename'] = dl_filename
|
||||
files.append(file_data)
|
||||
elif item_type == 'voice':
|
||||
voice_info = item.get('voice', {}) or {}
|
||||
download_url = voice_info.get('url')
|
||||
item_aeskey = voice_info.get('aeskey', '')
|
||||
voice_data = {
|
||||
'url': download_url,
|
||||
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
|
||||
@@ -387,13 +530,14 @@ async def parse_wecom_bot_message(
|
||||
if voice_info.get('content'):
|
||||
texts.append(voice_info.get('content'))
|
||||
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
voice_base64 = await _safe_download(download_url)
|
||||
voice_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
|
||||
if voice_base64:
|
||||
voice_data['base64'] = voice_base64
|
||||
voices.append(voice_data)
|
||||
elif item_type == 'video':
|
||||
video_info = item.get('video', {}) or {}
|
||||
download_url = video_info.get('url')
|
||||
item_aeskey = video_info.get('aeskey', '')
|
||||
video_data = {
|
||||
'url': download_url,
|
||||
'filesize': video_info.get('filesize') or video_info.get('size'),
|
||||
@@ -402,7 +546,7 @@ async def parse_wecom_bot_message(
|
||||
'filename': video_info.get('filename') or video_info.get('name'),
|
||||
}
|
||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||
video_base64 = await _safe_download(download_url)
|
||||
video_base64 = await _safe_download_as_data_uri(download_url, item_aeskey)
|
||||
if video_base64:
|
||||
video_data['base64'] = video_base64
|
||||
videos.append(video_data)
|
||||
@@ -483,14 +627,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。
|
||||
@@ -498,13 +655,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]:
|
||||
@@ -560,9 +720,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:
|
||||
@@ -571,7 +736,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]:
|
||||
@@ -696,11 +861,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)
|
||||
|
||||
@@ -769,8 +1001,20 @@ 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):
|
||||
return await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||
data, _filename = await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||
if data:
|
||||
return _bytes_to_data_uri(data)
|
||||
return None
|
||||
|
||||
async def run_task(self, host: str, port: int, *args, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -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', '')
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -105,6 +105,28 @@ class HTTPController:
|
||||
):
|
||||
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
||||
path += '.html'
|
||||
elif path.startswith('home/'):
|
||||
# SPA fallback for /home/* sub-routes.
|
||||
# Entity detail views use query params (e.g. /home/bots?id=uuid),
|
||||
# so the pre-rendered list page is served directly via path + '.html'.
|
||||
# This fallback handles any remaining unmatched sub-paths.
|
||||
segments = path.rstrip('/').split('/')
|
||||
|
||||
# Walk up parent segments looking for matching .html files
|
||||
for i in range(len(segments) - 1, 0, -1):
|
||||
parent_path = '/'.join(segments[:i]) + '.html'
|
||||
if os.path.exists(os.path.join(frontend_path, parent_path)):
|
||||
response = await quart.send_from_directory(frontend_path, parent_path, mimetype='text/html')
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
# Final fallback to index.html for /home/* routes
|
||||
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||
response.headers['Pragma'] = 'no-cache'
|
||||
response.headers['Expires'] = '0'
|
||||
return response
|
||||
else:
|
||||
return await quart.send_from_directory(frontend_path, '404.html')
|
||||
|
||||
|
||||
@@ -16,6 +16,57 @@ class MonitoringService:
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
# ========== Cleanup Methods ==========
|
||||
|
||||
async def cleanup_expired_records(self, retention_days: int) -> dict[str, int]:
|
||||
"""Delete monitoring records older than the specified retention period.
|
||||
|
||||
Args:
|
||||
retention_days: Number of days to retain records.
|
||||
|
||||
Returns:
|
||||
A dict mapping table name to the number of deleted rows.
|
||||
"""
|
||||
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
|
||||
days=retention_days
|
||||
)
|
||||
|
||||
tables_and_columns: list[tuple[str, type, sqlalchemy.Column]] = [
|
||||
(
|
||||
'monitoring_messages',
|
||||
persistence_monitoring.MonitoringMessage,
|
||||
persistence_monitoring.MonitoringMessage.timestamp,
|
||||
),
|
||||
(
|
||||
'monitoring_llm_calls',
|
||||
persistence_monitoring.MonitoringLLMCall,
|
||||
persistence_monitoring.MonitoringLLMCall.timestamp,
|
||||
),
|
||||
(
|
||||
'monitoring_embedding_calls',
|
||||
persistence_monitoring.MonitoringEmbeddingCall,
|
||||
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
||||
),
|
||||
(
|
||||
'monitoring_errors',
|
||||
persistence_monitoring.MonitoringError,
|
||||
persistence_monitoring.MonitoringError.timestamp,
|
||||
),
|
||||
(
|
||||
'monitoring_sessions',
|
||||
persistence_monitoring.MonitoringSession,
|
||||
persistence_monitoring.MonitoringSession.last_activity,
|
||||
),
|
||||
]
|
||||
|
||||
deleted_counts: dict[str, int] = {}
|
||||
|
||||
for table_name, model_cls, ts_column in tables_and_columns:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model_cls).where(ts_column < cutoff))
|
||||
deleted_counts[table_name] = result.rowcount
|
||||
|
||||
return deleted_counts
|
||||
|
||||
# ========== Recording Methods ==========
|
||||
|
||||
async def record_message(
|
||||
@@ -1132,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
|
||||
]
|
||||
|
||||
@@ -188,6 +188,34 @@ class Application:
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
|
||||
# Start monitoring data cleanup task if enabled
|
||||
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||
if auto_cleanup_cfg.get('enabled', True):
|
||||
retention_days = auto_cleanup_cfg.get('retention_days', 30)
|
||||
check_interval_hours = auto_cleanup_cfg.get('check_interval_hours', 1)
|
||||
|
||||
async def monitoring_cleanup_loop():
|
||||
check_interval_seconds = check_interval_hours * 3600
|
||||
while True:
|
||||
try:
|
||||
deleted = await self.monitoring_service.cleanup_expired_records(retention_days)
|
||||
total_deleted = sum(deleted.values())
|
||||
if total_deleted > 0:
|
||||
self.logger.info(
|
||||
f'Monitoring auto-cleanup: deleted {total_deleted} expired records '
|
||||
f'(retention={retention_days}d): {deleted}'
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Monitoring auto-cleanup error: {e}')
|
||||
await asyncio.sleep(check_interval_seconds)
|
||||
|
||||
self.task_mgr.create_task(
|
||||
monitoring_cleanup_loop(),
|
||||
name='monitoring-cleanup',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
|
||||
self.task_mgr.create_task(
|
||||
never_ending(),
|
||||
name='never-ending-task',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,18 +2,16 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import typing
|
||||
import json
|
||||
import uuid
|
||||
|
||||
|
||||
import sqlalchemy.ext.asyncio as sqlalchemy_asyncio
|
||||
import sqlalchemy
|
||||
|
||||
from . import database, migration
|
||||
from ..entity.persistence import base, pipeline, metadata, model as persistence_model
|
||||
from ..entity.persistence import base, metadata, model as persistence_model
|
||||
from ..entity import persistence
|
||||
from ..core import app
|
||||
from ..utils import constants, importutil
|
||||
from ..api.http.service import pipeline as pipeline_service
|
||||
from . import databases, migrations
|
||||
|
||||
importutil.import_modules_in_pkg(databases)
|
||||
@@ -78,7 +76,6 @@ class PersistenceManager:
|
||||
|
||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
||||
|
||||
await self.write_default_pipeline()
|
||||
await self.write_space_model_providers()
|
||||
|
||||
async def create_tables(self):
|
||||
@@ -101,29 +98,6 @@ class PersistenceManager:
|
||||
if row is None:
|
||||
await self.execute_async(sqlalchemy.insert(metadata.Metadata).values(item))
|
||||
|
||||
async def write_default_pipeline(self):
|
||||
# write default pipeline
|
||||
result = await self.execute_async(sqlalchemy.select(pipeline.LegacyPipeline))
|
||||
default_pipeline_uuid = None
|
||||
if result.first() is None:
|
||||
self.ap.logger.info('Creating default pipeline...')
|
||||
|
||||
pipeline_config = json.loads(importutil.read_resource_file('templates/default-pipeline-config.json'))
|
||||
|
||||
default_pipeline_uuid = str(uuid.uuid4())
|
||||
pipeline_data = {
|
||||
'uuid': default_pipeline_uuid,
|
||||
'for_version': self.ap.ver_mgr.get_current_version(),
|
||||
'stages': pipeline_service.default_stage_order,
|
||||
'is_default': True,
|
||||
'name': 'ChatPipeline',
|
||||
'description': 'Default pipeline, new bots will be bound to this pipeline | 默认提供的流水线,您配置的机器人将自动绑定到此流水线',
|
||||
'config': pipeline_config,
|
||||
'extensions_preferences': {},
|
||||
}
|
||||
|
||||
await self.execute_async(sqlalchemy.insert(pipeline.LegacyPipeline).values(pipeline_data))
|
||||
|
||||
async def write_space_model_providers(self):
|
||||
space_models_gateway_api_url = self.ap.instance_config.data.get('space', {}).get(
|
||||
'models_gateway_api_url', 'https://api.langbot.cloud/v1'
|
||||
|
||||
@@ -9,6 +9,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
|
||||
|
||||
@@ -141,6 +142,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:
|
||||
|
||||
@@ -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: ""
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -277,14 +277,8 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
return {'stream': success}
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
"""智能机器人侧默认开启流式能力。
|
||||
|
||||
Returns:
|
||||
bool: 恒定返回 True。
|
||||
|
||||
Example:
|
||||
流水线执行阶段会调用此方法以确认是否启用流式。"""
|
||||
return True
|
||||
"""Whether streaming output is enabled for this bot instance."""
|
||||
return self.config.get('enable-stream-reply', True)
|
||||
|
||||
async def send_message(self, target_type, target_id, message):
|
||||
_ws_mode = not self.config.get('enable-webhook', False)
|
||||
@@ -317,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())
|
||||
|
||||
@@ -324,6 +321,45 @@ 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
|
||||
|
||||
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,12 +103,26 @@ 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: ""
|
||||
- name: enable-stream-reply
|
||||
label:
|
||||
en_US: Enable Stream Reply
|
||||
zh_Hans: 启用流式回复
|
||||
zh_Hant: 啟用串流回覆
|
||||
description:
|
||||
en_US: If enabled, the bot will use streaming mode to reply messages
|
||||
zh_Hans: 如果启用,机器人将使用流式模式回复消息
|
||||
zh_Hant: 如果啟用,機器人將使用串流模式回覆訊息
|
||||
type: boolean
|
||||
required: false
|
||||
default: true
|
||||
execution:
|
||||
python:
|
||||
path: ./wecombot.py
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,6 +78,14 @@ plugin:
|
||||
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||
enable_marketplace: true
|
||||
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
||||
monitoring:
|
||||
auto_cleanup:
|
||||
# Enable automatic cleanup of expired monitoring records
|
||||
enabled: true
|
||||
# Retention period in days, records older than this will be deleted
|
||||
retention_days: 30
|
||||
# Cleanup check interval in hours
|
||||
check_interval_hours: 1
|
||||
space:
|
||||
# Space service URL for OAuth and API
|
||||
url: 'https://space.langbot.app'
|
||||
|
||||
@@ -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
|
||||
@@ -74,6 +74,10 @@ stages:
|
||||
type: integer
|
||||
required: true
|
||||
default: 10
|
||||
show_if:
|
||||
field: __system.is_wizard
|
||||
operator: neq
|
||||
value: true
|
||||
- name: prompt
|
||||
label:
|
||||
en_US: Prompt
|
||||
@@ -83,6 +87,9 @@ stages:
|
||||
zh_Hans: 除非您了解消息结构,否则请只使用 system 单提示词
|
||||
type: prompt-editor
|
||||
required: true
|
||||
default:
|
||||
- role: system
|
||||
content: "You are a helpful assistant."
|
||||
- name: knowledge-bases
|
||||
label:
|
||||
en_US: Knowledge Bases
|
||||
@@ -93,26 +100,10 @@ stages:
|
||||
type: knowledge-base-multi-selector
|
||||
required: false
|
||||
default: []
|
||||
- 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
|
||||
- name: app-id
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用 ID
|
||||
type: string
|
||||
required: true
|
||||
show_if:
|
||||
field: __system.is_wizard
|
||||
operator: neq
|
||||
value: true
|
||||
- name: dify-service-api
|
||||
label:
|
||||
en_US: Dify Service API
|
||||
@@ -127,6 +118,12 @@ 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:
|
||||
en_US: Base PROMPT
|
||||
@@ -163,52 +160,7 @@ stages:
|
||||
zh_Hans: API 密钥
|
||||
type: string
|
||||
required: true
|
||||
- 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
|
||||
- name: app-id
|
||||
label:
|
||||
en_US: App ID
|
||||
zh_Hans: 应用 ID
|
||||
type: string
|
||||
required: true
|
||||
- name: references_quote
|
||||
label:
|
||||
en_US: References Quote
|
||||
zh_Hans: 引用文本
|
||||
description:
|
||||
en_US: The text prompt when the references are included
|
||||
zh_Hans: 包含引用资料时的文本提示
|
||||
type: string
|
||||
required: false
|
||||
default: '参考资料来自:'
|
||||
default: 'your-api-key'
|
||||
- name: n8n-service-api
|
||||
label:
|
||||
en_US: n8n Workflow API
|
||||
@@ -226,6 +178,7 @@ stages:
|
||||
zh_Hans: n8n 工作流的 webhook URL
|
||||
type: string
|
||||
required: true
|
||||
default: 'http://your-n8n-webhook-url'
|
||||
- name: auth-type
|
||||
label:
|
||||
en_US: Authentication Type
|
||||
@@ -263,6 +216,10 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: auth-type
|
||||
operator: eq
|
||||
value: 'basic'
|
||||
- name: basic-password
|
||||
label:
|
||||
en_US: Password
|
||||
@@ -273,6 +230,10 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: auth-type
|
||||
operator: eq
|
||||
value: 'basic'
|
||||
- name: jwt-secret
|
||||
label:
|
||||
en_US: Secret
|
||||
@@ -283,6 +244,10 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: auth-type
|
||||
operator: eq
|
||||
value: 'jwt'
|
||||
- name: jwt-algorithm
|
||||
label:
|
||||
en_US: Algorithm
|
||||
@@ -293,6 +258,10 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: 'HS256'
|
||||
show_if:
|
||||
field: auth-type
|
||||
operator: eq
|
||||
value: 'jwt'
|
||||
- name: header-name
|
||||
label:
|
||||
en_US: Header Name
|
||||
@@ -303,6 +272,10 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: auth-type
|
||||
operator: eq
|
||||
value: 'header'
|
||||
- name: header-value
|
||||
label:
|
||||
en_US: Header Value
|
||||
@@ -313,6 +286,10 @@ stages:
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
show_if:
|
||||
field: auth-type
|
||||
operator: eq
|
||||
value: 'header'
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
@@ -333,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
|
||||
@@ -350,6 +461,7 @@ stages:
|
||||
zh_Hans: Langflow 服务器的基础 URL
|
||||
type: string
|
||||
required: true
|
||||
default: 'http://localhost:7860'
|
||||
- name: api-key
|
||||
label:
|
||||
en_US: API Key
|
||||
@@ -359,6 +471,7 @@ stages:
|
||||
zh_Hans: Langflow 服务器的 API 密钥
|
||||
type: string
|
||||
required: true
|
||||
default: 'your-api-key'
|
||||
- name: flow-id
|
||||
label:
|
||||
en_US: Flow ID
|
||||
@@ -368,6 +481,7 @@ stages:
|
||||
zh_Hans: 要运行的流程 ID
|
||||
type: string
|
||||
required: true
|
||||
default: 'your-flow-id'
|
||||
- name: input-type
|
||||
label:
|
||||
en_US: Input Type
|
||||
@@ -397,57 +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
|
||||
- name: bot-id
|
||||
label:
|
||||
en_US: Bot ID
|
||||
zh_Hans: 机器人 ID
|
||||
description:
|
||||
en_US: The ID of the bot to run
|
||||
zh_Hans: 要运行的机器人 ID
|
||||
type: string
|
||||
required: true
|
||||
- name: api-base
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础 URL
|
||||
description:
|
||||
en_US: The base URL for the Coze API, please use https://api.coze.com for global Coze edition(coze.com).
|
||||
zh_Hans: Coze API 的基础 URL,请使用 https://api.coze.com 用于全球 Coze 版(coze.com)
|
||||
type: string
|
||||
default: "https://api.coze.cn"
|
||||
- name: auto-save-history
|
||||
label:
|
||||
en_US: Auto Save History
|
||||
zh_Hans: 自动保存历史
|
||||
description:
|
||||
en_US: Whether to automatically save conversation history
|
||||
zh_Hans: 是否自动保存对话历史
|
||||
type: boolean
|
||||
default: true
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Request Timeout
|
||||
zh_Hans: 请求超时
|
||||
description:
|
||||
en_US: Timeout in seconds for API requests
|
||||
zh_Hans: API 请求超时时间(秒)
|
||||
type: number
|
||||
default: 120
|
||||
default: '{}'
|
||||
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.6" },
|
||||
{ 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.6"
|
||||
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/ff/f0/e5561bd1ebda0b9345ad6b98718b5f002bb3ca79b5ec294dc77cc10957b9/langbot_plugin-0.3.6.tar.gz", hash = "sha256:20db981e416a640f22246e54517abc2a095d8ccf5e69e06c2674fb8a443f5dbe", size = 179266, upload-time = "2026-03-30T15:58:58.523Z" }
|
||||
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/a3/f5/ac424c2620e1be98a54a0b8ec0ed256a9c06cea7cd32a30732a1aea5fdc5/langbot_plugin-0.3.6-py3-none-any.whl", hash = "sha256:3238448436c41d50a0a0cf37438d845f0a1371159d440af3411a984e3d4e9eb7", size = 156752, upload-time = "2026-03-30T15:59:00.229Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.1",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.15",
|
||||
@@ -33,6 +34,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",
|
||||
|
||||
4451
web/pnpm-lock.yaml
generated
4451
web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -46,8 +46,12 @@ function SpaceOAuthCallbackContent() {
|
||||
}
|
||||
setStatus('success');
|
||||
toast.success(t('common.spaceLoginSuccess'));
|
||||
|
||||
// If wizard state exists, redirect back to wizard instead of home
|
||||
const wizardState = localStorage.getItem('langbot_wizard_state');
|
||||
const redirectTo = wizardState ? '/wizard' : '/home';
|
||||
setTimeout(() => {
|
||||
router.push('/home');
|
||||
router.push(redirectTo);
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
|
||||
@@ -114,22 +114,23 @@
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--animate-twinkle: twinkle 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.08 0.002 285.823);
|
||||
--background: oklch(0.17 0.003 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.12 0.004 285.885);
|
||||
--card: oklch(0.16 0.004 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.12 0.004 285.885);
|
||||
--popover: oklch(0.16 0.004 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.62 0.2 255);
|
||||
--primary-foreground: oklch(1 0 0);
|
||||
--secondary: oklch(0.18 0.004 286.033);
|
||||
--secondary: oklch(0.27 0.005 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.18 0.004 286.033);
|
||||
--muted: oklch(0.27 0.005 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.18 0.004 286.033);
|
||||
--accent: oklch(0.27 0.005 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 8%);
|
||||
@@ -140,7 +141,7 @@
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.1 0.003 285.885);
|
||||
--sidebar: oklch(0.05 0.002 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.62 0.2 255);
|
||||
--sidebar-primary-foreground: oklch(1 0 0);
|
||||
@@ -158,3 +159,23 @@
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes twinkle {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.6;
|
||||
transform: scale(0.85) rotate(-8deg);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: scale(1.15) rotate(4deg);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.7;
|
||||
transform: scale(0.95) rotate(-4deg);
|
||||
}
|
||||
}
|
||||
|
||||
319
web/src/app/home/bots/BotDetailContent.tsx
Normal file
319
web/src/app/home/bots/BotDetailContent.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
|
||||
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
|
||||
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
|
||||
import type { BotSessionMonitorHandle } from '@/app/home/bots/components/bot-session/BotSessionMonitor';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Settings, FileText, Users, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function BotDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { refreshBots, bots, setDetailEntityName } = useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
setDetailEntityName(t('bots.createBot'));
|
||||
} else {
|
||||
const bot = bots.find((b) => b.id === id);
|
||||
setDetailEntityName(bot?.name ?? id);
|
||||
}
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, bots, setDetailEntityName, t]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('config');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
|
||||
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
|
||||
|
||||
// Track whether the form has unsaved changes
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
|
||||
// Enable state managed here so the header switch works
|
||||
const [botEnabled, setBotEnabled] = useState(true);
|
||||
const [enableLoaded, setEnableLoaded] = useState(false);
|
||||
|
||||
// Fetch bot enable state
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) {
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
setEnableLoaded(true);
|
||||
});
|
||||
}
|
||||
}, [id, isCreateMode]);
|
||||
|
||||
const handleEnableToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
const prev = botEnabled;
|
||||
setBotEnabled(checked);
|
||||
try {
|
||||
// Fetch current bot data to send a complete update
|
||||
const res = await httpClient.getBot(id);
|
||||
const bot = res.bot;
|
||||
await httpClient.updateBot(id, {
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
adapter: bot.adapter,
|
||||
adapter_config: bot.adapter_config,
|
||||
enable: checked,
|
||||
});
|
||||
refreshBots();
|
||||
} catch {
|
||||
setBotEnabled(prev);
|
||||
toast.error(t('bots.setBotEnableError'));
|
||||
}
|
||||
},
|
||||
[id, botEnabled, refreshBots, t],
|
||||
);
|
||||
|
||||
function handleFormSubmit() {
|
||||
// Re-sync enable state after form save (form may update enable too)
|
||||
httpClient.getBot(id).then((res) => {
|
||||
setBotEnabled(res.bot.enable ?? true);
|
||||
});
|
||||
refreshBots();
|
||||
}
|
||||
|
||||
function handleBotDeleted() {
|
||||
refreshBots();
|
||||
router.push('/home/bots');
|
||||
}
|
||||
|
||||
function handleNewBotCreated(newBotId: string) {
|
||||
refreshBots();
|
||||
router.push(`/home/bots?id=${encodeURIComponent(newBotId)}`);
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
httpClient
|
||||
.deleteBot(id)
|
||||
.then(() => {
|
||||
setShowDeleteConfirm(false);
|
||||
toast.success(t('bots.deleteSuccess'));
|
||||
handleBotDeleted();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('bots.deleteError') + err.msg);
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Create Mode ====================
|
||||
if (isCreateMode) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('bots.createBot')}</h1>
|
||||
<Button type="submit" form="bot-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl pb-8">
|
||||
<BotForm
|
||||
initBotId={undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sticky Header: title + enable switch + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">{t('bots.editBot')}</h1>
|
||||
{enableLoaded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="bot-enable-switch"
|
||||
checked={botEnabled}
|
||||
onCheckedChange={handleEnableToggle}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="bot-enable-switch"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{t('common.enable')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" form="bot-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
<Tabs
|
||||
key={id}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-1 flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="shrink-0">
|
||||
<TabsTrigger value="config" className="gap-1.5">
|
||||
<Settings className="size-3.5" />
|
||||
{t('bots.configuration')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="logs" className="gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
{t('bots.logs')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sessions" className="gap-1.5">
|
||||
<Users className="size-3.5" />
|
||||
{t('bots.sessionMonitor.title')}
|
||||
{activeTab === 'sessions' && (
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center ml-0.5"
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
if (isRefreshingSessions) return;
|
||||
setIsRefreshingSessions(true);
|
||||
const minDelay = new Promise((r) => setTimeout(r, 500));
|
||||
Promise.all([
|
||||
sessionMonitorRef.current?.refreshSessions(),
|
||||
minDelay,
|
||||
]).finally(() => setIsRefreshingSessions(false));
|
||||
}}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'size-3 text-muted-foreground hover:text-foreground transition-colors',
|
||||
isRefreshingSessions && 'animate-spin',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab: Configuration */}
|
||||
<TabsContent
|
||||
value="config"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<BotForm
|
||||
initBotId={id}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
|
||||
{/* Card: Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('bots.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('bots.deleteBotAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('bots.deleteBotHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Logs */}
|
||||
<TabsContent
|
||||
value="logs"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<BotLogListComponent botId={id} />
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Sessions */}
|
||||
<TabsContent value="sessions" className="flex-1 min-h-0 mt-4">
|
||||
<BotSessionMonitor ref={sessionMonitorRef} botId={id} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('bots.deleteConfirmation')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{t('bots.deleteConfirmation')}</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import BotForm from '@/app/home/bots/components/bot-form/BotForm';
|
||||
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent';
|
||||
import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
interface BotDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
botId?: string;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onFormSubmit: (value: z.infer<any>) => void;
|
||||
onFormCancel: () => void;
|
||||
onBotDeleted: () => void;
|
||||
onNewBotCreated: (botId: string) => void;
|
||||
}
|
||||
|
||||
export default function BotDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
botId: propBotId,
|
||||
onFormSubmit,
|
||||
onFormCancel,
|
||||
onBotDeleted,
|
||||
onNewBotCreated,
|
||||
}: BotDetailDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [botId, setBotId] = useState<string | undefined>(propBotId);
|
||||
const [activeMenu, setActiveMenu] = useState('config');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setBotId(propBotId);
|
||||
setActiveMenu('config');
|
||||
}, [propBotId, open]);
|
||||
|
||||
const menu = [
|
||||
{
|
||||
key: 'config',
|
||||
label: t('bots.configuration'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
label: t('bots.logs'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sessions',
|
||||
label: t('bots.sessionMonitor.title'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleFormSubmit = (value: any) => {
|
||||
onFormSubmit(value);
|
||||
};
|
||||
|
||||
const handleFormCancel = () => {
|
||||
onFormCancel();
|
||||
};
|
||||
|
||||
const handleBotDeleted = () => {
|
||||
httpClient.deleteBot(botId ?? '').then(() => {
|
||||
onBotDeleted();
|
||||
});
|
||||
};
|
||||
|
||||
const handleNewBotCreated = (newBotId: string) => {
|
||||
setBotId(newBotId);
|
||||
setActiveMenu('config');
|
||||
onNewBotCreated(newBotId);
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
handleBotDeleted();
|
||||
setShowDeleteConfirm(false);
|
||||
};
|
||||
|
||||
if (!botId) {
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex">
|
||||
<main className="flex flex-1 flex-col h-[70vh]">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle>{t('bots.createBot')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('bots.createBot')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<BotForm
|
||||
initBotId={undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onBotDeleted={handleBotDeleted}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="px-6 py-4 border-t shrink-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" form="bot-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleFormCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</main>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 !max-w-[70rem] max-h-[75vh] flex">
|
||||
<SidebarProvider className="items-start w-full flex">
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="hidden md:flex h-[80vh] w-40 min-w-[120px] border-r bg-white dark:bg-black"
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{menu.map((item) => (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={activeMenu === item.key}
|
||||
onClick={() => setActiveMenu(item.key)}
|
||||
>
|
||||
<a href="#">
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
<main className="flex flex-1 flex-col h-[75vh]">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle>
|
||||
{activeMenu === 'config'
|
||||
? t('bots.editBot')
|
||||
: activeMenu === 'logs'
|
||||
? t('bots.botLogTitle')
|
||||
: t('bots.sessionMonitor.title')}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{activeMenu === 'config'
|
||||
? t('bots.editBot')
|
||||
: activeMenu === 'logs'
|
||||
? t('bots.botLogTitle')
|
||||
: t('bots.sessionMonitor.title')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div
|
||||
className={
|
||||
activeMenu === 'sessions'
|
||||
? 'flex-1 min-h-0'
|
||||
: 'flex-1 overflow-y-auto px-6 pb-6'
|
||||
}
|
||||
>
|
||||
{activeMenu === 'config' && (
|
||||
<BotForm
|
||||
initBotId={botId}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onBotDeleted={handleBotDeleted}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
)}
|
||||
{activeMenu === 'logs' && botId && (
|
||||
<BotLogListComponent botId={botId} />
|
||||
)}
|
||||
{activeMenu === 'sessions' && botId && (
|
||||
<BotSessionMonitor botId={botId} />
|
||||
)}
|
||||
</div>
|
||||
{activeMenu === 'config' && (
|
||||
<DialogFooter className="px-6 py-4 border-t shrink-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
<Button type="submit" form="bot-form">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleFormCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('bots.deleteConfirmation')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{t('bots.deleteConfirmation')}</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
height: 10rem;
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid #e4e4e7;
|
||||
padding: 1.2rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
@@ -11,15 +11,15 @@
|
||||
|
||||
:global(.dark) .cardContainer {
|
||||
background-color: #1f1f22;
|
||||
box-shadow: 0;
|
||||
border-color: #27272a;
|
||||
}
|
||||
|
||||
.cardContainer:hover {
|
||||
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);
|
||||
border-color: #a1a1aa;
|
||||
}
|
||||
|
||||
:global(.dark) .cardContainer:hover {
|
||||
box-shadow: 0;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
.iconBasicInfoContainer {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import i18n from 'i18next';
|
||||
import {
|
||||
IChooseAdapterEntity,
|
||||
IPipelineEntity,
|
||||
@@ -13,26 +14,19 @@ 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 { 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -44,19 +38,28 @@ import {
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} 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({
|
||||
name: z.string().min(1, { message: t('bots.botNameRequired') }),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, { message: t('bots.botDescriptionRequired') }),
|
||||
description: z.string().optional(),
|
||||
adapter: z.string().min(1, { message: t('bots.adapterRequired') }),
|
||||
adapter_config: z.record(z.string(), z.any()),
|
||||
enable: z.boolean().optional(),
|
||||
@@ -66,13 +69,13 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
export default function BotForm({
|
||||
initBotId,
|
||||
onFormSubmit,
|
||||
onBotDeleted,
|
||||
onNewBotCreated,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initBotId?: string;
|
||||
onFormSubmit: (value: z.infer<ReturnType<typeof getFormSchema>>) => void;
|
||||
onBotDeleted: () => void;
|
||||
onNewBotCreated: (botId: string) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
@@ -81,7 +84,7 @@ export default function BotForm({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: t('bots.defaultDescription'),
|
||||
description: '',
|
||||
adapter: '',
|
||||
adapter_config: {},
|
||||
enable: true,
|
||||
@@ -89,22 +92,22 @@ export default function BotForm({
|
||||
},
|
||||
});
|
||||
|
||||
const [showDeleteConfirmModal, setShowDeleteConfirmModal] = useState(false);
|
||||
// Track whether initial data loading is complete.
|
||||
// setValue calls during init should NOT mark the form as dirty.
|
||||
const isInitializing = useRef(true);
|
||||
|
||||
const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
|
||||
useState(new Map<string, IDynamicFormItemSchema[]>());
|
||||
// const [form] = Form.useForm<IBotFormEntity>();
|
||||
const [showDynamicForm, setShowDynamicForm] = useState<boolean>(false);
|
||||
// const [dynamicForm] = Form.useForm();
|
||||
const [adapterNameList, setAdapterNameList] = useState<
|
||||
IChooseAdapterEntity[]
|
||||
>([]);
|
||||
const [adapterIconList, setAdapterIconList] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [adapterDescriptionList, setAdapterDescriptionList] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [adapterHelpLinks, setAdapterHelpLinks] = useState<
|
||||
Record<string, Record<string, string>>
|
||||
>({});
|
||||
|
||||
const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(
|
||||
[],
|
||||
@@ -116,89 +119,45 @@ 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;
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
// 降级:创建临时textarea复制
|
||||
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(() => {
|
||||
// 拉取初始化表单信息
|
||||
if (initBotId) {
|
||||
getBotConfig(initBotId)
|
||||
.then((val) => {
|
||||
form.setValue('name', val.name);
|
||||
form.setValue('description', val.description);
|
||||
form.setValue('adapter', val.adapter);
|
||||
form.setValue('adapter_config', val.adapter_config);
|
||||
form.setValue('enable', val.enable);
|
||||
form.setValue('use_pipeline_uuid', val.use_pipeline_uuid || '');
|
||||
// Use form.reset() to set values AND update the dirty baseline,
|
||||
// so isDirty stays false after initial load.
|
||||
form.reset({
|
||||
name: val.name,
|
||||
description: val.description,
|
||||
adapter: val.adapter,
|
||||
adapter_config: val.adapter_config,
|
||||
enable: val.enable,
|
||||
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
||||
});
|
||||
handleAdapterSelect(val.adapter);
|
||||
// dynamicForm.setFieldsValue(val.adapter_config);
|
||||
|
||||
// 设置 webhook 地址(如果有)
|
||||
if (val.webhook_full_url) {
|
||||
setWebhookUrl(val.webhook_full_url);
|
||||
} else {
|
||||
@@ -210,50 +169,42 @@ export default function BotForm({
|
||||
toast.error(
|
||||
t('bots.getBotConfigError') + (err as CustomApiError).msg,
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
isInitializing.current = false;
|
||||
});
|
||||
} else {
|
||||
form.reset();
|
||||
setWebhookUrl('');
|
||||
setExtraWebhookUrl('');
|
||||
isInitializing.current = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function initBotFormComponent() {
|
||||
// 初始化流水线列表
|
||||
const pipelinesRes = await httpClient.getPipelines();
|
||||
setPipelineNameList(
|
||||
pipelinesRes.pipelines.map((item) => {
|
||||
return {
|
||||
label: item.name,
|
||||
value: item.uuid ?? '',
|
||||
emoji: item.emoji,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// 拉取adapter
|
||||
const adaptersRes = await httpClient.getAdapters();
|
||||
setAdapterNameList(
|
||||
adaptersRes.adapters.map((item) => {
|
||||
return {
|
||||
label: extractI18nObject(item.label),
|
||||
value: item.name,
|
||||
categories: item.spec.categories,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
// 初始化适配器图标列表
|
||||
setAdapterIconList(
|
||||
adaptersRes.adapters.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = httpClient.getAdapterIconURL(item.name);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
),
|
||||
);
|
||||
|
||||
// 初始化适配器描述列表
|
||||
setAdapterDescriptionList(
|
||||
adaptersRes.adapters.reduce(
|
||||
(acc, item) => {
|
||||
@@ -264,7 +215,18 @@ export default function BotForm({
|
||||
),
|
||||
);
|
||||
|
||||
// 初始化适配器表单map
|
||||
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,
|
||||
@@ -341,15 +303,13 @@ export default function BotForm({
|
||||
}
|
||||
}
|
||||
|
||||
// 只有通过外层固定表单验证才会走到这里,真正的提交逻辑在这里
|
||||
function onDynamicFormSubmit() {
|
||||
setIsLoading(true);
|
||||
if (initBotId) {
|
||||
// 编辑提交
|
||||
const updateBot: Bot = {
|
||||
uuid: initBotId,
|
||||
name: form.getValues().name,
|
||||
description: form.getValues().description,
|
||||
description: form.getValues().description ?? '',
|
||||
adapter: form.getValues().adapter,
|
||||
adapter_config: form.getValues().adapter_config,
|
||||
enable: form.getValues().enable,
|
||||
@@ -358,6 +318,8 @@ export default function BotForm({
|
||||
httpClient
|
||||
.updateBot(initBotId, updateBot)
|
||||
.then(() => {
|
||||
// Reset dirty baseline to current values so isDirty becomes false
|
||||
form.reset(form.getValues());
|
||||
onFormSubmit(form.getValues());
|
||||
toast.success(t('bots.saveSuccess'));
|
||||
})
|
||||
@@ -366,14 +328,11 @@ export default function BotForm({
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
// form.reset();
|
||||
// dynamicForm.resetFields();
|
||||
});
|
||||
} else {
|
||||
// 创建提交
|
||||
const newBot: Bot = {
|
||||
name: form.getValues().name,
|
||||
description: form.getValues().description,
|
||||
description: form.getValues().description ?? '',
|
||||
adapter: form.getValues().adapter,
|
||||
adapter_config: form.getValues().adapter_config,
|
||||
};
|
||||
@@ -393,181 +352,24 @@ export default function BotForm({
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
form.reset();
|
||||
// dynamicForm.resetFields();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function deleteBot() {
|
||||
if (initBotId) {
|
||||
httpClient
|
||||
.deleteBot(initBotId)
|
||||
.then(() => {
|
||||
onBotDeleted();
|
||||
toast.success(t('bots.deleteSuccess'));
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('bots.deleteError') + err.msg);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Dialog
|
||||
open={showDeleteConfirmModal}
|
||||
onOpenChange={setShowDeleteConfirmModal}
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="bot-form"
|
||||
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogDescription>{t('bots.deleteConfirmation')}</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirmModal(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
deleteBot();
|
||||
setShowDeleteConfirmModal(false);
|
||||
}}
|
||||
>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="bot-form"
|
||||
onSubmit={form.handleSubmit(onDynamicFormSubmit)}
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* 是否启用 & 绑定流水线 仅在编辑模式 */}
|
||||
{initBotId && (
|
||||
<>
|
||||
<div className="flex items-center gap-6">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="enable"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
|
||||
<FormLabel>{t('common.enable')}</FormLabel>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="use_pipeline_uuid"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col justify-start gap-[0.8rem] h-[3.8rem]">
|
||||
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} {...field}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue
|
||||
placeholder={t('bots.selectPipeline')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="fixed z-[1000]">
|
||||
<SelectGroup>
|
||||
{pipelineNameList.map((item) => (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Webhook 地址显示(统一 Webhook 模式) */}
|
||||
{webhookUrl &&
|
||||
(currentAdapter !== 'lark' || enableWebhook !== false) && (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
value={webhookUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
||||
onClick={(e) => {
|
||||
// 点击输入框时自动全选
|
||||
(e.target as HTMLInputElement).select();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(webhookUrl, setCopied)}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600 mr-2" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
{extraWebhookUrl && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Input
|
||||
value={extraWebhookUrl}
|
||||
readOnly
|
||||
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
||||
onClick={(e) => {
|
||||
(e.target as HTMLInputElement).select();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
copyToClipboard(extraWebhookUrl, setExtraCopied)
|
||||
}
|
||||
>
|
||||
{extraCopied ? (
|
||||
<Check className="h-4 w-4 text-green-600 mr-2" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{t('common.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{extraWebhookUrl
|
||||
? t('bots.webhookUrlHintEither')
|
||||
: t('bots.webhookUrlHint')}
|
||||
</p>
|
||||
</FormItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Card 1: Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.basicInfo')}</CardTitle>
|
||||
<CardDescription>{t('bots.basicInfoDescription')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
@@ -575,7 +377,7 @@ export default function BotForm({
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.botName')}
|
||||
<span className="text-red-500">*</span>
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
@@ -589,10 +391,7 @@ export default function BotForm({
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.botDescription')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormLabel>{t('bots.botDescription')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -600,7 +399,84 @@ export default function BotForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Card 2: Pipeline Binding (edit mode only) */}
|
||||
{initBotId && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.routingConnection')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.routingConnectionDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="use_pipeline_uuid"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} {...field}>
|
||||
<SelectTrigger>
|
||||
{field.value ? (
|
||||
(() => {
|
||||
const pipeline = pipelineNameList.find(
|
||||
(p) => p.value === field.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{pipeline?.emoji && (
|
||||
<span className="text-sm shrink-0">
|
||||
{pipeline.emoji}
|
||||
</span>
|
||||
)}
|
||||
<span>{pipeline?.label ?? field.value}</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={t('bots.selectPipeline')}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{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>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 3: Adapter Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('bots.adapterConfig')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('bots.adapterConfigDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="adapter"
|
||||
@@ -608,10 +484,10 @@ export default function BotForm({
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('bots.platformAdapter')}
|
||||
<span className="text-red-500">*</span>
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<div className="relative">
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
@@ -619,65 +495,102 @@ export default function BotForm({
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('bots.selectAdapter')} />
|
||||
<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')}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="fixed z-[1000]">
|
||||
<SelectGroup>
|
||||
{adapterNameList.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
<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>
|
||||
))}
|
||||
</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>
|
||||
{adapterDescriptionList[currentAdapter]}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch('adapter') && (
|
||||
<div className="flex items-start gap-3 p-4 rounded-lg border">
|
||||
<img
|
||||
src={adapterIconList[form.watch('adapter')]}
|
||||
alt="adapter icon"
|
||||
className="w-12 h-12 rounded-[8%]"
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-medium">
|
||||
{
|
||||
adapterNameList.find(
|
||||
(item) => item.value === form.watch('adapter'),
|
||||
)?.label
|
||||
}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500">
|
||||
{adapterDescriptionList[form.watch('adapter')]}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showDynamicForm && dynamicFormConfigList.length > 0 && (
|
||||
<DynamicFormComponent
|
||||
itemConfigList={dynamicFormConfigList}
|
||||
initialValues={currentAdapterConfig}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
}}
|
||||
systemContext={{
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showDynamicForm && filteredDynamicFormConfigList.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-lg font-medium">
|
||||
{t('bots.adapterConfig')}
|
||||
</div>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={filteredDynamicFormConfigList}
|
||||
initialValues={currentAdapterConfig}
|
||||
onSubmit={(values) => {
|
||||
form.setValue('adapter_config', values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
export interface IChooseAdapterEntity {
|
||||
label: string;
|
||||
value: string;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export interface IPipelineEntity {
|
||||
label: string;
|
||||
value: string;
|
||||
emoji?: string;
|
||||
}
|
||||
|
||||
@@ -2,220 +2,187 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { BotLog } from '@/app/infra/http/requestParam/bots/GetBotLogsResponse';
|
||||
import styles from './botLog.module.css';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { PhotoProvider } from 'react-photo-view';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Check, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function BotLogCard({ botLog }: { botLog: BotLog }) {
|
||||
const LEVEL_STYLES: Record<string, string> = {
|
||||
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
warning:
|
||||
'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
debug: 'bg-muted text-muted-foreground',
|
||||
};
|
||||
|
||||
const SHORT_TEXT_LIMIT = 120;
|
||||
|
||||
export function BotLogCard({
|
||||
botLog,
|
||||
defaultExpanded = false,
|
||||
}: {
|
||||
botLog: BotLog;
|
||||
defaultExpanded?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const baseURL = httpClient.getBaseUrl();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
function copySessionId() {
|
||||
const text = botLog.message_session_id;
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast.success(t('common.copySuccess'));
|
||||
})
|
||||
.catch(() => fallbackCopy(text));
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback 复制方法,用于不支持 clipboard API 的环境
|
||||
function fallbackCopy(text: string) {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-9999px';
|
||||
textArea.style.top = '-9999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast.success(t('common.copySuccess'));
|
||||
} catch {
|
||||
toast.error(t('common.copyFailed'));
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number) {
|
||||
const now = new Date();
|
||||
const date = new Date(timestamp * 1000);
|
||||
|
||||
// 获取各个时间部分
|
||||
const year = date.getFullYear();
|
||||
const month = date.getMonth() + 1; // 月份从0开始,需要+1
|
||||
const day = date.getDate();
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
|
||||
// 判断时间范围
|
||||
const isToday = now.toDateString() === date.toDateString();
|
||||
const isYesterday =
|
||||
new Date(now.setDate(now.getDate() - 1)).toDateString() ===
|
||||
date.toDateString();
|
||||
const isThisYear = now.getFullYear() === year;
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const isYesterday = yesterday.toDateString() === date.toDateString();
|
||||
const isThisYear = now.getFullYear() === date.getFullYear();
|
||||
|
||||
if (isToday) {
|
||||
return `${hours}:${minutes}`; // 今天的消息:小时:分钟
|
||||
} else if (isYesterday) {
|
||||
return `${t('bots.yesterday')} ${hours}:${minutes}`; // 昨天的消息:昨天 小时:分钟
|
||||
} else if (isThisYear) {
|
||||
return t('bots.dateFormat', { month, day }); // 本年消息:x月x日
|
||||
} else {
|
||||
return t('bots.earlier'); // 更早的消息:更久之前
|
||||
}
|
||||
if (isToday) return `${hours}:${minutes}`;
|
||||
if (isYesterday) return `${t('bots.yesterday')} ${hours}:${minutes}`;
|
||||
if (isThisYear)
|
||||
return t('bots.dateFormat', {
|
||||
month: date.getMonth() + 1,
|
||||
day: date.getDate(),
|
||||
});
|
||||
return t('bots.earlier');
|
||||
}
|
||||
|
||||
function getSubChatId(str: string) {
|
||||
const strArr = str.split('');
|
||||
return strArr;
|
||||
}
|
||||
|
||||
// 根据日志级别返回对应的样式类
|
||||
function getLevelStyles(level: string) {
|
||||
switch (level.toLowerCase()) {
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||||
case 'warning':
|
||||
return 'bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400';
|
||||
case 'info':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'debug':
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
// 截取文本的简短版本
|
||||
function getShortText(text: string, maxLength: number = 100) {
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + '...';
|
||||
}
|
||||
|
||||
// 判断是否需要展开按钮
|
||||
const needsExpand = botLog.text.length > 100 || botLog.images.length > 0;
|
||||
const needsExpand =
|
||||
botLog.text.length > SHORT_TEXT_LIMIT || botLog.images.length > 0;
|
||||
const levelStyle =
|
||||
LEVEL_STYLES[botLog.level.toLowerCase()] ?? LEVEL_STYLES.debug;
|
||||
|
||||
return (
|
||||
<div className={`${styles.botLogCardContainer}`}>
|
||||
{/* 头部标签,时间 */}
|
||||
<div className={`${styles.cardTitleContainer}`}>
|
||||
<div className={`flex flex-row gap-2 items-center`}>
|
||||
<div
|
||||
className={`px-2 py-1 rounded text-xs font-medium uppercase ${getLevelStyles(
|
||||
botLog.level,
|
||||
)}`}
|
||||
<div className="rounded-lg border bg-card px-3.5 py-3 transition-colors hover:border-border/80">
|
||||
{/* Header: level badge, session id, expand toggle, timestamp */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Level badge */}
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex shrink-0 items-center rounded px-1.5 py-0.5 text-[11px] font-semibold uppercase leading-none',
|
||||
levelStyle,
|
||||
)}
|
||||
>
|
||||
{botLog.level}
|
||||
</div>
|
||||
</span>
|
||||
|
||||
{/* Session ID */}
|
||||
{botLog.message_session_id && (
|
||||
<div
|
||||
className={`${styles.tag} ${styles.chatTag} relative`}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// 兼容性更好的复制方法
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(botLog.message_session_id)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast.success(t('common.copySuccess'));
|
||||
})
|
||||
.catch(() => {
|
||||
// fallback
|
||||
fallbackCopy(botLog.message_session_id);
|
||||
});
|
||||
} else {
|
||||
fallbackCopy(botLog.message_session_id);
|
||||
}
|
||||
copySessionId();
|
||||
}}
|
||||
title={t('common.clickToCopy')}
|
||||
className="inline-flex items-center gap-1 rounded bg-muted px-1.5 py-0.5 text-[11px] font-mono text-muted-foreground hover:bg-accent hover:text-accent-foreground transition-colors truncate max-w-48 cursor-pointer"
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
<Check className="size-3 shrink-0 text-green-600" />
|
||||
) : (
|
||||
<svg
|
||||
className="icon"
|
||||
viewBox="0 0 1024 1024"
|
||||
version="1.1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
p-id="1664"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M96.1 575.7a32.2 32.1 0 1 0 64.4 0 32.2 32.1 0 1 0-64.4 0Z"
|
||||
p-id="1665"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
<path
|
||||
d="M742.1 450.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.1 26-13.8 26-31s-11.7-31.3-26-31.4zM742.1 577.7l-269.5-2.1c-14.3-0.1-26 13.8-26 31s11.7 31.3 26 31.4l269.5 2.1c14.3 0.2 26-13.8 26-31s-11.7-31.3-26-31.4z"
|
||||
p-id="1666"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
<path
|
||||
d="M736.1 63.9H417c-70.4 0-128 57.6-128 128h-64.9c-70.4 0-128 57.6-128 128v128c-0.1 17.7 14.4 32 32.2 32 17.8 0 32.2-14.4 32.2-32.1V320c0-35.2 28.8-64 64-64H289v447.8c0 70.4 57.6 128 128 128h255.1c-0.1 35.2-28.8 63.8-64 63.8H224.5c-35.2 0-64-28.8-64-64V703.5c0-17.7-14.4-32.1-32.2-32.1-17.8 0-32.3 14.4-32.3 32.1v128.3c0 70.4 57.6 128 128 128h384.1c70.4 0 128-57.6 128-128h65c70.4 0 128-57.6 128-128V255.9l-193-192z m0.1 63.4l127.7 128.3H800c-35.2 0-64-28.8-64-64v-64.3h0.2z m64 641H416.1c-35.2 0-64-28.8-64-64v-513c0-35.2 28.8-64 64-64H671V191c0 70.4 57.6 128 128 128h65.2v385.3c0 35.2-28.8 64-64 64z"
|
||||
p-id="1667"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
<Copy className="size-3 shrink-0" />
|
||||
)}
|
||||
|
||||
<span className={`${styles.chatId}`}>
|
||||
{getSubChatId(botLog.message_session_id)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="truncate">{botLog.message_session_id}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{needsExpand && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 transition-colors"
|
||||
className="flex items-center gap-0.5 text-[11px] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
<ChevronDown className="size-3" />
|
||||
{t('bots.collapse')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
<ChevronRight className="size-3" />
|
||||
{t('bots.viewDetails')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className={`${styles.timestamp}`}>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{formatTime(botLog.timestamp)}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 日志内容 - 简化显示 */}
|
||||
<div className={`${styles.cardText}`}>
|
||||
{expanded ? botLog.text : getShortText(botLog.text)}
|
||||
{/* Log text */}
|
||||
<div className="mt-2 text-sm leading-relaxed text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
||||
{expanded
|
||||
? botLog.text
|
||||
: botLog.text.length > SHORT_TEXT_LIMIT
|
||||
? botLog.text.slice(0, SHORT_TEXT_LIMIT) + '...'
|
||||
: botLog.text}
|
||||
</div>
|
||||
|
||||
{/* 图片 - 只在展开时显示 */}
|
||||
{/* Images (expanded) */}
|
||||
{expanded && botLog.images.length > 0 && (
|
||||
<PhotoProvider>
|
||||
<div className={`flex flex-wrap gap-2 mt-3`}>
|
||||
<div className="flex flex-wrap gap-2 mt-2.5">
|
||||
{botLog.images.map((item) => (
|
||||
<img
|
||||
key={item}
|
||||
src={`${baseURL}/api/v1/files/image/${item}`}
|
||||
alt=""
|
||||
className="max-w-xs rounded cursor-pointer hover:opacity-90 transition-opacity"
|
||||
className="max-w-xs rounded-md cursor-pointer hover:opacity-90 transition-opacity"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PhotoProvider>
|
||||
)}
|
||||
|
||||
{/* 图片数量提示 - 未展开时显示 */}
|
||||
{/* Image count hint (collapsed) */}
|
||||
{!expanded && botLog.images.length > 0 && (
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
📷 {botLog.images.length} {t('bots.imagesAttached')}
|
||||
<div className="mt-1.5 text-[11px] text-muted-foreground">
|
||||
{botLog.images.length} {t('bots.imagesAttached')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
import { BotLogCard } from '@/app/home/bots/components/bot-log/view/BotLogCard';
|
||||
import styles from './botLog.module.css';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Popover,
|
||||
@@ -18,7 +17,20 @@ import { debounce } from 'lodash';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
export function BotLogListComponent({
|
||||
botId,
|
||||
autoExpandImages = false,
|
||||
hideDetailedLogsLink = false,
|
||||
hideToolbar = false,
|
||||
}: {
|
||||
botId: string;
|
||||
/** When true, log entries with images are rendered expanded by default */
|
||||
autoExpandImages?: boolean;
|
||||
/** When true, hides the "View Detailed Logs" navigation button */
|
||||
hideDetailedLogsLink?: boolean;
|
||||
/** When true, hides the entire toolbar (auto-refresh, level filter, detailed logs link) */
|
||||
hideToolbar?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const manager = useRef(new BotLogManager(botId)).current;
|
||||
@@ -50,7 +62,6 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
botLogListRef.current = botLogList;
|
||||
}, [botLogList]);
|
||||
|
||||
// 根据级别过滤日志
|
||||
const filteredLogs = useMemo(() => {
|
||||
if (selectedLevels.length === 0) {
|
||||
return botLogList;
|
||||
@@ -75,18 +86,15 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
if (selectedLevels.length === logLevels.length) {
|
||||
return t('bots.allLevels');
|
||||
}
|
||||
// 如果选中3个或以上,显示数量
|
||||
if (selectedLevels.length >= 3) {
|
||||
return `${selectedLevels.length} ${t('bots.levelsSelected')}`;
|
||||
}
|
||||
// 显示选中级别的标签(大写形式)
|
||||
return logLevels
|
||||
.filter((level) => selectedLevels.includes(level.value))
|
||||
.map((level) => level.label)
|
||||
.join(', ');
|
||||
};
|
||||
|
||||
// 观测自动刷新状态
|
||||
useEffect(() => {
|
||||
if (autoFlush) {
|
||||
manager.startListenServerPush();
|
||||
@@ -99,13 +107,10 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
}, [autoFlush]);
|
||||
|
||||
function initComponent() {
|
||||
// 订阅日志推送
|
||||
manager.subscribeLogPush(handleBotLogPush);
|
||||
// 加载第一页日志
|
||||
manager.loadFirstPage().then((response) => {
|
||||
setBotLogList(response.reverse());
|
||||
});
|
||||
// 监听滚动
|
||||
listenScroll();
|
||||
}
|
||||
|
||||
@@ -115,28 +120,19 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
}
|
||||
|
||||
function listenScroll() {
|
||||
if (!listContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
const list = listContainerRef.current;
|
||||
list.addEventListener('scroll', handleScroll);
|
||||
if (!listContainerRef.current) return;
|
||||
listContainerRef.current.addEventListener('scroll', handleScroll);
|
||||
}
|
||||
|
||||
function removeScrollListener() {
|
||||
if (!listContainerRef.current) {
|
||||
return;
|
||||
}
|
||||
const list = listContainerRef.current;
|
||||
list.removeEventListener('scroll', handleScroll);
|
||||
if (!listContainerRef.current) return;
|
||||
listContainerRef.current.removeEventListener('scroll', handleScroll);
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
// 加载更多日志
|
||||
const list = botLogListRef.current;
|
||||
const lastSeq = list[list.length - 1].seq_id;
|
||||
if (lastSeq === 0) {
|
||||
return;
|
||||
}
|
||||
if (lastSeq === 0) return;
|
||||
manager.loadMore(lastSeq - 1, 10).then((response) => {
|
||||
setBotLogList([...list, ...response.reverse()]);
|
||||
});
|
||||
@@ -165,63 +161,101 @@ export function BotLogListComponent({ botId }: { botId: string }) {
|
||||
if (!isTop && !isBottom) {
|
||||
setAutoFlush(false);
|
||||
}
|
||||
}, 300), // 防抖延迟 300ms
|
||||
[botLogList], // 依赖项为空
|
||||
}, 300),
|
||||
[botLogList],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`${styles.botLogListContainer}`} ref={listContainerRef}>
|
||||
<div className={`${styles.listHeader}`}>
|
||||
<div className={'mr-2'}>{t('bots.enableAutoRefresh')}</div>
|
||||
<Switch checked={autoFlush} onCheckedChange={(e) => setAutoFlush(e)} />
|
||||
<div className={'ml-4 mr-2'}>{t('bots.logLevel')}</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className="flex flex-col h-full min-h-0 overflow-y-auto"
|
||||
ref={listContainerRef}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
{!hideToolbar && (
|
||||
<div className="flex items-center gap-3 pb-3 shrink-0 flex-wrap">
|
||||
{/* Auto-refresh toggle */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('bots.enableAutoRefresh')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={autoFlush}
|
||||
onCheckedChange={(v) => setAutoFlush(v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Level filter */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('bots.logLevel')}
|
||||
</span>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-[160px] justify-between"
|
||||
>
|
||||
<span className="text-sm truncate">{getDisplayText()}</span>
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[160px] p-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{logLevels.map((level) => (
|
||||
<div
|
||||
key={level.value}
|
||||
className="flex items-center space-x-2"
|
||||
>
|
||||
<Checkbox
|
||||
id={level.value}
|
||||
checked={selectedLevels.includes(level.value)}
|
||||
onCheckedChange={() => handleLevelToggle(level.value)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={level.value}
|
||||
className="text-sm font-medium leading-none cursor-pointer"
|
||||
>
|
||||
{level.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{/* Link to detailed logs */}
|
||||
{!hideDetailedLogsLink && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-[180px] flex items-center justify-between"
|
||||
className="gap-1"
|
||||
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
|
||||
>
|
||||
<span className="text-sm truncate flex-1 text-left">
|
||||
{getDisplayText()}
|
||||
</span>
|
||||
<ChevronDownIcon className="ml-2 h-4 w-4 flex-shrink-0" />
|
||||
<ExternalLink className="size-3.5" />
|
||||
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[180px] p-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
{logLevels.map((level) => (
|
||||
<div key={level.value} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={level.value}
|
||||
checked={selectedLevels.includes(level.value)}
|
||||
onCheckedChange={() => handleLevelToggle(level.value)}
|
||||
/>
|
||||
<label
|
||||
htmlFor={level.value}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
>
|
||||
{level.label}
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-4 flex items-center gap-1"
|
||||
onClick={() => router.push(`/home/monitoring?botId=${botId}`)}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
<span className="text-sm">{t('bots.viewDetailedLogs')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredLogs.map((botLog) => {
|
||||
return <BotLogCard botLog={botLog} key={botLog.seq_id} />;
|
||||
})}
|
||||
{/* Log cards */}
|
||||
{filteredLogs.length === 0 ? (
|
||||
<div className="flex-1 flex items-center justify-center py-12">
|
||||
<p className="text-sm text-muted-foreground">{t('bots.noLogs')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{filteredLogs.map((botLog) => (
|
||||
<BotLogCard
|
||||
botLog={botLog}
|
||||
key={botLog.seq_id}
|
||||
defaultExpanded={autoExpandImages && botLog.images.length > 0}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
.botLogListContainer {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-height: 10rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.botLogCardContainer {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
background-color: #fff;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
transition: all 0.2s ease;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.botLogCardContainer:hover {
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .botLogCardContainer {
|
||||
background-color: #1f1f22;
|
||||
border: 1px solid #2a2a2e;
|
||||
}
|
||||
|
||||
:global(.dark) .botLogCardContainer:hover {
|
||||
border-color: #3a3a3e;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.listHeader {
|
||||
width: 100%;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.25rem;
|
||||
height: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
max-width: 16rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
:global(.dark) .tag {
|
||||
background-color: #1e3a8a;
|
||||
color: #93c5fd;
|
||||
}
|
||||
|
||||
.chatTag {
|
||||
color: #4b5563;
|
||||
background-color: #f3f4f6;
|
||||
text-transform: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.chatTag:hover {
|
||||
background-color: #e5e7eb;
|
||||
}
|
||||
|
||||
:global(.dark) .chatTag {
|
||||
color: #9ca3af;
|
||||
background-color: #374151;
|
||||
}
|
||||
|
||||
:global(.dark) .chatTag:hover {
|
||||
background-color: #4b5563;
|
||||
}
|
||||
|
||||
.chatId {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas,
|
||||
'Courier New', monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.cardTitleContainer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.cardText {
|
||||
color: #1e293b;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.7;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
word-break: break-all;
|
||||
overflow-wrap: anywhere;
|
||||
hyphens: auto;
|
||||
max-width: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', sans-serif;
|
||||
}
|
||||
|
||||
:global(.dark) .cardText {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
color: #64748b;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:global(.dark) .timestamp {
|
||||
color: #64748b;
|
||||
}
|
||||
@@ -1,10 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import {
|
||||
@@ -49,11 +55,18 @@ interface SessionMessage {
|
||||
role?: string | null;
|
||||
}
|
||||
|
||||
export interface BotSessionMonitorHandle {
|
||||
refreshSessions: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface BotSessionMonitorProps {
|
||||
botId: string;
|
||||
}
|
||||
|
||||
export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
const BotSessionMonitor = forwardRef<
|
||||
BotSessionMonitorHandle,
|
||||
BotSessionMonitorProps
|
||||
>(function BotSessionMonitor({ botId }, ref) {
|
||||
const { t } = useTranslation();
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
|
||||
@@ -97,6 +110,14 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
}
|
||||
}, [botId]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
refreshSessions: loadSessions,
|
||||
}),
|
||||
[loadSessions],
|
||||
);
|
||||
|
||||
const loadMessages = useCallback(async (sessionId: string) => {
|
||||
setLoadingMessages(true);
|
||||
try {
|
||||
@@ -235,7 +256,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="mb-2 pl-2.5 border-l-2 border-gray-300 dark:border-gray-600 opacity-80"
|
||||
className="mb-2 pl-2.5 border-l-2 border-muted-foreground/50 opacity-80"
|
||||
>
|
||||
<div className="text-sm">
|
||||
{quote.origin?.map((comp, idx) =>
|
||||
@@ -278,9 +299,17 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
);
|
||||
};
|
||||
|
||||
// Backend timestamps may lack timezone indicator; treat as UTC
|
||||
const parseTimestamp = (timestamp: string): Date => {
|
||||
if (!timestamp) return new Date(0);
|
||||
const hasTimezone =
|
||||
timestamp.endsWith('Z') || /[+-]\d{2}:?\d{2}$/.test(timestamp);
|
||||
return new Date(hasTimezone ? timestamp : timestamp + 'Z');
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string): string => {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
const date = parseTimestamp(timestamp);
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
return `${hours}:${minutes}`;
|
||||
@@ -288,7 +317,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
|
||||
const formatRelativeTime = (timestamp: string): string => {
|
||||
if (!timestamp) return '';
|
||||
const date = new Date(timestamp);
|
||||
const date = parseTimestamp(timestamp);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffMins = Math.floor(diffMs / 60000);
|
||||
@@ -306,36 +335,9 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
||||
{/* Left Panel: Session List */}
|
||||
<div className="w-64 flex-shrink-0 border-r flex flex-col min-h-0">
|
||||
{/* Refresh Button */}
|
||||
<div className="px-2 py-2 border-b shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full h-9 text-sm text-muted-foreground"
|
||||
onClick={loadSessions}
|
||||
disabled={loadingSessions}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn(
|
||||
'w-3.5 h-3.5 mr-1.5',
|
||||
loadingSessions && 'animate-spin',
|
||||
)}
|
||||
>
|
||||
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
|
||||
</svg>
|
||||
{t('bots.sessionMonitor.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0">
|
||||
{/* Session List */}
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
{loadingSessions && sessions.length === 0 ? (
|
||||
@@ -347,14 +349,15 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
{t('bots.sessionMonitor.noSessions')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
<div className="p-1.5">
|
||||
{sessions.map((session) => {
|
||||
const isSelected = selectedSessionId === session.session_id;
|
||||
return (
|
||||
<button
|
||||
key={session.session_id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
|
||||
'w-full text-left px-2.5 py-2 rounded-md transition-colors',
|
||||
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
||||
)}
|
||||
onClick={() => setSelectedSessionId(session.session_id)}
|
||||
@@ -403,20 +406,20 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
{/* Right Panel: Messages */}
|
||||
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||
{!selectedSessionId ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground text-sm flex-1 flex items-center justify-center">
|
||||
{t('bots.sessionMonitor.selectSession')}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Chat Header */}
|
||||
<div className="px-6 py-3 border-b shrink-0 flex items-center justify-between">
|
||||
<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>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
{parseSessionType(selectedSessionId) && (
|
||||
<span>{parseSessionType(selectedSessionId)}</span>
|
||||
)}
|
||||
@@ -433,6 +436,7 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
{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')}
|
||||
@@ -462,40 +466,20 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="w-8 h-8"
|
||||
onClick={() => loadMessages(selectedSessionId)}
|
||||
disabled={loadingMessages}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={cn('w-4 h-4', loadingMessages && 'animate-spin')}
|
||||
>
|
||||
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Messages Area — matches DebugDialog style */}
|
||||
{/* Messages Area */}
|
||||
<ScrollArea
|
||||
ref={messagesContainerRef}
|
||||
className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black"
|
||||
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
{loadingMessages ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.loading')}
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="text-center text-muted-foreground py-12 text-lg">
|
||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||
{t('bots.sessionMonitor.noMessages')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -511,21 +495,18 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-3xl px-5 py-3 rounded-2xl',
|
||||
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
|
||||
isUser
|
||||
? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 rounded-br-none'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
|
||||
? 'bg-primary/10 rounded-br-sm'
|
||||
: 'bg-muted rounded-bl-sm',
|
||||
msg.status === 'error' && 'ring-1 ring-red-400/50',
|
||||
)}
|
||||
>
|
||||
{renderMessageContent(msg)}
|
||||
{/* Role label + timestamp inside bubble, matching DebugDialog */}
|
||||
{/* Role label + timestamp */}
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs mt-2 flex items-center gap-2',
|
||||
isUser
|
||||
? 'text-gray-600 dark:text-gray-300'
|
||||
: 'text-gray-500 dark:text-gray-400',
|
||||
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
@@ -561,4 +542,6 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default BotSessionMonitor;
|
||||
|
||||
@@ -1,144 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import styles from './botConfig.module.css';
|
||||
import { BotCardVO } from '@/app/home/bots/components/bot-card/BotCardVO';
|
||||
import BotCard from '@/app/home/bots/components/bot-card/BotCard';
|
||||
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { Bot, Adapter } from '@/app/infra/entities/api';
|
||||
import { toast } from 'sonner';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import BotDetailDialog from '@/app/home/bots/BotDetailDialog';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
import BotDetailContent from './BotDetailContent';
|
||||
|
||||
export default function BotConfigPage() {
|
||||
const { t } = useTranslation();
|
||||
// 机器人详情dialog
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState<boolean>(false);
|
||||
const [botList, setBotList] = useState<BotCardVO[]>([]);
|
||||
const [selectedBotId, setSelectedBotId] = useState<string>('');
|
||||
const searchParams = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
useEffect(() => {
|
||||
getBotList();
|
||||
}, []);
|
||||
|
||||
async function getBotList() {
|
||||
const adapterListResp = await httpClient.getAdapters();
|
||||
const adapterList = adapterListResp.adapters.map((adapter: Adapter) => {
|
||||
return {
|
||||
label: extractI18nObject(adapter.label),
|
||||
value: adapter.name,
|
||||
};
|
||||
});
|
||||
|
||||
httpClient
|
||||
.getBots()
|
||||
.then((resp) => {
|
||||
const botList: BotCardVO[] = resp.bots.map((bot: Bot) => {
|
||||
return new BotCardVO({
|
||||
id: bot.uuid || '',
|
||||
iconURL: httpClient.getAdapterIconURL(bot.adapter),
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
adapter: bot.adapter,
|
||||
adapterConfig: bot.adapter_config,
|
||||
adapterLabel:
|
||||
adapterList.find((item) => item.value === bot.adapter)?.label ||
|
||||
bot.adapter.substring(0, 10),
|
||||
usePipelineName: bot.use_pipeline_name || '',
|
||||
enable: bot.enable || false,
|
||||
});
|
||||
});
|
||||
setBotList(botList);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('get bot list error', err);
|
||||
toast.error(t('bots.getBotListError') + (err as CustomApiError).msg);
|
||||
});
|
||||
}
|
||||
|
||||
function handleCreateBotClick() {
|
||||
const maxBots = systemInfo.limitation?.max_bots ?? -1;
|
||||
if (maxBots >= 0 && botList.length >= maxBots) {
|
||||
toast.error(t('limitation.maxBotsReached', { max: maxBots }));
|
||||
return;
|
||||
}
|
||||
setSelectedBotId('');
|
||||
setDetailDialogOpen(true);
|
||||
}
|
||||
|
||||
function selectBot(botUUID: string) {
|
||||
setSelectedBotId(botUUID);
|
||||
setDetailDialogOpen(true);
|
||||
}
|
||||
|
||||
function handleFormSubmit() {
|
||||
getBotList();
|
||||
// setDetailDialogOpen(false);
|
||||
}
|
||||
|
||||
function handleFormCancel() {
|
||||
setDetailDialogOpen(false);
|
||||
}
|
||||
|
||||
function handleBotDeleted() {
|
||||
getBotList();
|
||||
setDetailDialogOpen(false);
|
||||
}
|
||||
|
||||
function handleNewBotCreated(botId: string) {
|
||||
getBotList();
|
||||
setSelectedBotId(botId);
|
||||
if (detailId) {
|
||||
return <BotDetailContent id={detailId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<BotDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
botId={selectedBotId || undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onFormCancel={handleFormCancel}
|
||||
onBotDeleted={handleBotDeleted}
|
||||
onNewBotCreated={handleNewBotCreated}
|
||||
/>
|
||||
|
||||
{/* 注意:其余的返回内容需要保持在Spin组件外部 */}
|
||||
<div className={`${styles.botListContainer}`}>
|
||||
<CreateCardComponent
|
||||
width={'100%'}
|
||||
height={'10rem'}
|
||||
plusSize={'90px'}
|
||||
onClick={handleCreateBotClick}
|
||||
/>
|
||||
{botList.map((cardVO) => {
|
||||
return (
|
||||
<div
|
||||
key={cardVO.id}
|
||||
onClick={() => {
|
||||
selectBot(cardVO.id);
|
||||
}}
|
||||
>
|
||||
<BotCard
|
||||
botCardVO={cardVO}
|
||||
setBotEnableCallback={(id, enable) => {
|
||||
setBotList(
|
||||
botList.map((bot) => {
|
||||
if (bot.id === id) {
|
||||
return { ...bot, enable: enable };
|
||||
}
|
||||
return bot;
|
||||
}),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('bots.selectFromSidebar')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,9 +11,138 @@ 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.
|
||||
*
|
||||
* Fields prefixed with `__system.` are looked up in the caller-supplied
|
||||
* `systemContext` dictionary (e.g. `__system.is_wizard` → `systemContext.is_wizard`).
|
||||
* All other field names are resolved from the live form values first, then
|
||||
* fall back to `externalDependentValues`.
|
||||
*/
|
||||
function resolveShowIfValue(
|
||||
field: string,
|
||||
watchedValues: Record<string, unknown>,
|
||||
externalDependentValues?: Record<string, unknown>,
|
||||
systemContext?: Record<string, unknown>,
|
||||
): unknown {
|
||||
if (field.startsWith('__system.')) {
|
||||
const key = field.slice('__system.'.length);
|
||||
return systemContext?.[key];
|
||||
}
|
||||
if (watchedValues[field] !== undefined) {
|
||||
return watchedValues[field];
|
||||
}
|
||||
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,
|
||||
@@ -22,6 +151,7 @@ export default function DynamicFormComponent({
|
||||
onFileUploaded,
|
||||
isEditing,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
}: {
|
||||
itemConfigList: IDynamicFormItemSchema[];
|
||||
onSubmit?: (val: object) => unknown;
|
||||
@@ -29,6 +159,9 @@ export default function DynamicFormComponent({
|
||||
onFileUploaded?: (fileKey: string) => void;
|
||||
isEditing?: boolean;
|
||||
externalDependentValues?: Record<string, unknown>;
|
||||
/** Extra variables accessible via the `__system.*` namespace in show_if conditions.
|
||||
* e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */
|
||||
systemContext?: Record<string, unknown>;
|
||||
}) {
|
||||
const isInitialMount = useRef(true);
|
||||
const previousInitialValues = useRef(initialValues);
|
||||
@@ -60,12 +193,26 @@ export default function DynamicFormComponent({
|
||||
fallbacks: [],
|
||||
};
|
||||
}
|
||||
if (item.type === 'prompt-editor') {
|
||||
if (Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
// Default to a single empty system prompt entry
|
||||
return [{ role: 'system', content: '' }];
|
||||
}
|
||||
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) {
|
||||
@@ -143,7 +290,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 {
|
||||
@@ -171,7 +318,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;
|
||||
@@ -186,7 +333,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();
|
||||
@@ -202,7 +349,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;
|
||||
@@ -222,7 +369,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;
|
||||
@@ -233,21 +380,19 @@ export default function DynamicFormComponent({
|
||||
previousInitialValues.current = finalValues as Record<string, object>;
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, itemConfigList]);
|
||||
}, [form, editableItems]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div className="space-y-4">
|
||||
{itemConfigList.map((config) => {
|
||||
if (config.show_if) {
|
||||
const dependValue =
|
||||
watchedValues[
|
||||
config.show_if.field as keyof typeof watchedValues
|
||||
] !== undefined
|
||||
? watchedValues[
|
||||
config.show_if.field as keyof typeof watchedValues
|
||||
]
|
||||
: externalDependentValues?.[config.show_if.field];
|
||||
const dependValue = resolveShowIfValue(
|
||||
config.show_if.field,
|
||||
watchedValues as Record<string, unknown>,
|
||||
externalDependentValues,
|
||||
systemContext,
|
||||
);
|
||||
|
||||
if (
|
||||
config.show_if.operator === 'eq' &&
|
||||
@@ -273,6 +418,69 @@ 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 (
|
||||
<FormField
|
||||
key={config.id}
|
||||
control={form.control}
|
||||
name={config.name as keyof FormValues}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-row items-center justify-between rounded-lg border p-4 max-w-2xl',
|
||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
{extractI18nObject(config.label)}
|
||||
</FormLabel>
|
||||
{config.description && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{extractI18nObject(config.description)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<DynamicFormItemComponent
|
||||
config={config}
|
||||
field={field}
|
||||
onFileUploaded={onFileUploaded}
|
||||
/>
|
||||
</FormControl>
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField
|
||||
key={config.id}
|
||||
|
||||
@@ -37,7 +37,29 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Plus, X, Eye, Wrench } 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,
|
||||
@@ -57,6 +79,25 @@ export default function DynamicFormItemComponent({
|
||||
const [kbDialogOpen, setKbDialogOpen] = useState(false);
|
||||
const [tempSelectedKBIds, setTempSelectedKBIds] = 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 +129,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 +176,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]);
|
||||
|
||||
@@ -181,27 +215,62 @@ export default function DynamicFormItemComponent({
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
className="max-w-xs"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.STRING:
|
||||
return <Input {...field} />;
|
||||
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:
|
||||
return <Textarea {...field} className="min-h-[120px]" />;
|
||||
return <Textarea {...field} className="min-h-[120px] max-w-2xl" />;
|
||||
|
||||
case DynamicFormItemType.BOOLEAN:
|
||||
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
|
||||
|
||||
case DynamicFormItemType.STRING_ARRAY:
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 max-w-md">
|
||||
{field.value.map((item: string, index: number) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<div key={index} className="flex gap-1.5 items-center">
|
||||
<Input
|
||||
className="w-[200px]"
|
||||
className="flex-1"
|
||||
value={item}
|
||||
onChange={(e) => {
|
||||
const newValue = [...field.value];
|
||||
@@ -209,9 +278,11 @@ export default function DynamicFormItemComponent({
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
<Button
|
||||
type="button"
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => {
|
||||
const newValue = field.value.filter(
|
||||
(_: string, i: number) => i !== index,
|
||||
@@ -219,24 +290,19 @@ export default function DynamicFormItemComponent({
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5 text-red-500"
|
||||
>
|
||||
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full border-dashed text-muted-foreground hover:text-foreground"
|
||||
onClick={() => {
|
||||
field.onChange([...field.value, '']);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-4 mr-1.5" />
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -245,13 +311,17 @@ export default function DynamicFormItemComponent({
|
||||
case DynamicFormItemType.SELECT:
|
||||
return (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectTrigger className="max-w-md bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('common.select')} />
|
||||
</SelectTrigger>
|
||||
<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>
|
||||
))}
|
||||
@@ -261,8 +331,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';
|
||||
@@ -273,32 +351,181 @@ 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 (
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 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) */}
|
||||
{(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>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
|
||||
@@ -314,30 +541,40 @@ export default function DynamicFormItemComponent({
|
||||
);
|
||||
|
||||
return (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedEmbeddingModels).map(
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="max-w-md">
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(groupedEmbeddingModels).map(
|
||||
([providerName, models]) => (
|
||||
<SelectGroup key={providerName}>
|
||||
<SelectLabel>{providerName}</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.uuid} value={model.uuid}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
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';
|
||||
@@ -348,6 +585,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 &&
|
||||
@@ -404,6 +662,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>
|
||||
);
|
||||
@@ -446,11 +810,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 */}
|
||||
@@ -495,11 +883,11 @@ export default function DynamicFormItemComponent({
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 text-destructive"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-muted-foreground hover:text-destructive"
|
||||
onClick={() => removeFallbackModel(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -512,10 +900,10 @@ export default function DynamicFormItemComponent({
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
className="w-full border-dashed text-muted-foreground hover:text-foreground"
|
||||
onClick={addFallbackModel}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
<Plus className="size-4 mr-1.5" />
|
||||
{t('models.fallback.addFallback')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -541,7 +929,25 @@ export default function DynamicFormItemComponent({
|
||||
return (
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('knowledge.selectKnowledgeBase')} />
|
||||
{field.value && field.value !== '__none__' ? (
|
||||
(() => {
|
||||
const selectedKb = knowledgeBases.find(
|
||||
(kb) => kb.uuid === field.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedKb?.emoji && (
|
||||
<span className="text-sm shrink-0">
|
||||
{selectedKb.emoji}
|
||||
</span>
|
||||
)}
|
||||
<span>{selectedKb?.name ?? field.value}</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<SelectValue placeholder={t('knowledge.selectKnowledgeBase')} />
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
@@ -553,7 +959,12 @@ export default function DynamicFormItemComponent({
|
||||
<SelectLabel>{engineName}</SelectLabel>
|
||||
{kbs.map((base) => (
|
||||
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
|
||||
{base.name}
|
||||
<div className="flex items-center gap-2">
|
||||
{base.emoji && (
|
||||
<span className="text-sm shrink-0">{base.emoji}</span>
|
||||
)}
|
||||
<span>{base.name}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
@@ -597,6 +1008,11 @@ export default function DynamicFormItemComponent({
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{currentKb.emoji && (
|
||||
<span className="text-sm shrink-0">
|
||||
{currentKb.emoji}
|
||||
</span>
|
||||
)}
|
||||
{currentKb.name}
|
||||
{currentKb.knowledge_engine?.name && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300">
|
||||
@@ -686,7 +1102,14 @@ export default function DynamicFormItemComponent({
|
||||
aria-label={`Select ${base.name}`}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{base.name}</div>
|
||||
<div className="font-medium flex items-center gap-2">
|
||||
{base.emoji && (
|
||||
<span className="text-sm shrink-0">
|
||||
{base.emoji}
|
||||
</span>
|
||||
)}
|
||||
{base.name}
|
||||
</div>
|
||||
{base.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{base.description}
|
||||
@@ -738,10 +1161,18 @@ export default function DynamicFormItemComponent({
|
||||
</Select>
|
||||
);
|
||||
|
||||
case DynamicFormItemType.PROMPT_EDITOR:
|
||||
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
|
||||
// single system-prompt entry to prevent the .map() crash.
|
||||
const promptItems: { role: string; content: string }[] = Array.isArray(
|
||||
field.value,
|
||||
)
|
||||
? field.value
|
||||
: [{ role: 'system', content: '' }];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{field.value.map(
|
||||
{promptItems.map(
|
||||
(item: { role: string; content: string }, index: number) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
{/* 角色选择 */}
|
||||
@@ -753,7 +1184,7 @@ export default function DynamicFormItemComponent({
|
||||
<Select
|
||||
value={item.role}
|
||||
onValueChange={(value) => {
|
||||
const newValue = [...field.value];
|
||||
const newValue = [...(field.value ?? promptItems)];
|
||||
newValue[index] = { ...newValue[index], role: value };
|
||||
field.onChange(newValue);
|
||||
}}
|
||||
@@ -774,7 +1205,7 @@ export default function DynamicFormItemComponent({
|
||||
className="w-[300px]"
|
||||
value={item.content}
|
||||
onChange={(e) => {
|
||||
const newValue = [...field.value];
|
||||
const newValue = [...(field.value ?? promptItems)];
|
||||
newValue[index] = {
|
||||
...newValue[index],
|
||||
content: e.target.value,
|
||||
@@ -788,7 +1219,7 @@ export default function DynamicFormItemComponent({
|
||||
type="button"
|
||||
className="p-2 hover:bg-gray-100 rounded"
|
||||
onClick={() => {
|
||||
const newValue = field.value.filter(
|
||||
const newValue = (field.value ?? promptItems).filter(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(_: any, i: number) => i !== index,
|
||||
);
|
||||
@@ -812,13 +1243,17 @@ export default function DynamicFormItemComponent({
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
field.onChange([...field.value, { role: 'user', content: '' }]);
|
||||
field.onChange([
|
||||
...(field.value ?? promptItems),
|
||||
{ role: 'user', content: '' },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
{t('common.addRound')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
case DynamicFormItemType.FILE:
|
||||
return (
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
.sidebarContainer {
|
||||
box-sizing: border-box;
|
||||
width: 11rem;
|
||||
height: 100vh;
|
||||
background-color: #eee;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding-block: 1rem;
|
||||
padding-left: 0.4rem;
|
||||
user-select: none;
|
||||
/* box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1); */
|
||||
}
|
||||
|
||||
:global(.dark) .sidebarContainer {
|
||||
background-color: #0a0a0b !important;
|
||||
}
|
||||
|
||||
.langbotIconContainer {
|
||||
width: 200px;
|
||||
height: 70px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.langbotIcon {
|
||||
width: 2.8rem;
|
||||
height: 2.8rem;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
:global(.dark) .langbotIcon {
|
||||
box-shadow: 0 0 10px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.langbotTextContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.langbotText {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
:global(.dark) .langbotText {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
color: #f0f0f0 !important;
|
||||
}
|
||||
|
||||
.langbotVersion {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #6c6c6c;
|
||||
}
|
||||
|
||||
:global(.dark) .langbotVersion {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
color: #a0a0a0 !important;
|
||||
}
|
||||
|
||||
.sidebarTopContainer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.sidebarItemsContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.8rem;
|
||||
}
|
||||
|
||||
.sidebarChildContainer {
|
||||
width: 9.8rem;
|
||||
height: 3rem;
|
||||
padding-left: 1.6rem;
|
||||
font-size: 1rem;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
cursor: pointer;
|
||||
gap: 0.5rem;
|
||||
transition: all 0.2s ease;
|
||||
/* background-color: aqua; */
|
||||
}
|
||||
|
||||
.sidebarSelected {
|
||||
background-color: #2288ee;
|
||||
color: white;
|
||||
box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .sidebarSelected {
|
||||
background-color: #2288ee;
|
||||
color: white;
|
||||
box-shadow: 0 0 10px 0 rgba(34, 136, 238, 0.3);
|
||||
}
|
||||
|
||||
.sidebarUnselected {
|
||||
color: #6c6c6c;
|
||||
}
|
||||
|
||||
:global(.dark) .sidebarUnselected {
|
||||
color: #a0a0a0 !important;
|
||||
}
|
||||
|
||||
.sidebarUnselected:hover {
|
||||
background-color: rgba(34, 136, 238, 0.1);
|
||||
color: #2288ee;
|
||||
}
|
||||
|
||||
:global(.dark) .sidebarUnselected:hover {
|
||||
background-color: rgba(34, 136, 238, 0.2);
|
||||
color: #66baff;
|
||||
}
|
||||
|
||||
.sidebarChildIcon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background-color: rgba(96, 149, 209, 0);
|
||||
}
|
||||
|
||||
.sidebarChildName {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.sidebarBottomContainer {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: auto;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.sidebarBottomChildContainer {
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
import styles from './HomeSidebar.module.css';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
export type SidebarSection = 'home' | 'extensions' | 'standalone';
|
||||
|
||||
export interface ISidebarChildVO {
|
||||
id: string;
|
||||
icon: React.ReactNode;
|
||||
@@ -8,6 +9,7 @@ export interface ISidebarChildVO {
|
||||
route: string;
|
||||
description: string;
|
||||
helpLink: I18nObject;
|
||||
section?: SidebarSection;
|
||||
}
|
||||
|
||||
export class SidebarChildVO {
|
||||
@@ -17,6 +19,7 @@ export class SidebarChildVO {
|
||||
route: string;
|
||||
description: string;
|
||||
helpLink: I18nObject;
|
||||
section: SidebarSection;
|
||||
|
||||
constructor(props: ISidebarChildVO) {
|
||||
this.id = props.id;
|
||||
@@ -25,29 +28,6 @@ export class SidebarChildVO {
|
||||
this.route = props.route;
|
||||
this.description = props.description;
|
||||
this.helpLink = props.helpLink;
|
||||
this.section = props.section ?? 'home';
|
||||
}
|
||||
}
|
||||
|
||||
export function SidebarChild({
|
||||
icon,
|
||||
name,
|
||||
isSelected,
|
||||
onClick,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
name: string;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`${styles.sidebarChildContainer} ${
|
||||
isSelected ? styles.sidebarSelected : styles.sidebarUnselected
|
||||
}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={`${styles.sidebarChildIcon}`}>{icon}</div>
|
||||
<span className={`${styles.sidebarChildName}`}>{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
242
web/src/app/home/components/home-sidebar/SidebarDataContext.tsx
Normal file
242
web/src/app/home/components/home-sidebar/SidebarDataContext.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { httpClient, getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { isNewerVersion } from '@/app/utils/versionCompare';
|
||||
|
||||
// Lightweight entity item for sidebar display
|
||||
export interface SidebarEntityItem {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
emoji?: string;
|
||||
iconURL?: string;
|
||||
updatedAt?: string; // ISO timestamp for sorting by most recently edited
|
||||
// Bot-specific fields
|
||||
enabled?: boolean;
|
||||
// MCP-specific fields
|
||||
runtimeStatus?: 'connecting' | 'connected' | 'error';
|
||||
// Plugin-specific fields
|
||||
installSource?: string;
|
||||
installInfo?: Record<string, unknown>;
|
||||
hasUpdate?: boolean;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
// Install action types that can be triggered from sidebar
|
||||
export type PluginInstallAction = 'local' | 'github' | null;
|
||||
|
||||
// Entity lists and refresh functions exposed via context
|
||||
export interface SidebarDataContextValue {
|
||||
bots: SidebarEntityItem[];
|
||||
pipelines: SidebarEntityItem[];
|
||||
knowledgeBases: SidebarEntityItem[];
|
||||
plugins: SidebarEntityItem[];
|
||||
mcpServers: SidebarEntityItem[];
|
||||
refreshBots: () => Promise<void>;
|
||||
refreshPipelines: () => Promise<void>;
|
||||
refreshKnowledgeBases: () => Promise<void>;
|
||||
refreshPlugins: () => Promise<void>;
|
||||
refreshMCPServers: () => Promise<void>;
|
||||
refreshAll: () => Promise<void>;
|
||||
// Breadcrumb: entity name shown when viewing a detail page
|
||||
detailEntityName: string | null;
|
||||
setDetailEntityName: (name: string | null) => void;
|
||||
// Pending plugin install action triggered from sidebar
|
||||
pendingPluginInstallAction: PluginInstallAction;
|
||||
setPendingPluginInstallAction: (action: PluginInstallAction) => void;
|
||||
}
|
||||
|
||||
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
|
||||
|
||||
export function SidebarDataProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [bots, setBots] = useState<SidebarEntityItem[]>([]);
|
||||
const [pipelines, setPipelines] = useState<SidebarEntityItem[]>([]);
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
|
||||
useState<PluginInstallAction>(null);
|
||||
|
||||
const refreshBots = useCallback(async () => {
|
||||
try {
|
||||
const resp = await httpClient.getBots();
|
||||
setBots(
|
||||
resp.bots.map((bot) => ({
|
||||
id: bot.uuid || '',
|
||||
name: bot.name,
|
||||
description: bot.description,
|
||||
iconURL: httpClient.getAdapterIconURL(bot.adapter),
|
||||
updatedAt: bot.updated_at,
|
||||
enabled: bot.enable ?? true,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch bots for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshPipelines = useCallback(async () => {
|
||||
try {
|
||||
const resp = await httpClient.getPipelines();
|
||||
setPipelines(
|
||||
resp.pipelines.map((p) => ({
|
||||
id: p.uuid || '',
|
||||
name: p.name,
|
||||
description: p.description,
|
||||
emoji: p.emoji,
|
||||
updatedAt: p.updated_at,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch pipelines for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshKnowledgeBases = useCallback(async () => {
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeBases();
|
||||
setKnowledgeBases(
|
||||
resp.bases.map((kb) => ({
|
||||
id: kb.uuid || '',
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
emoji: kb.emoji,
|
||||
updatedAt: kb.updated_at,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch knowledge bases for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshPlugins = useCallback(async () => {
|
||||
try {
|
||||
const [pluginsResp, marketplaceResp] = await Promise.all([
|
||||
httpClient.getPlugins(),
|
||||
getCloudServiceClientSync()
|
||||
.getMarketplacePlugins(1, 100)
|
||||
.catch(() => ({ plugins: [] })),
|
||||
]);
|
||||
|
||||
// Build marketplace version lookup: "author/name" -> latest_version
|
||||
const marketplaceVersions = new Map<string, string>();
|
||||
for (const mp of marketplaceResp.plugins) {
|
||||
if (mp.latest_version) {
|
||||
marketplaceVersions.set(`${mp.author}/${mp.name}`, mp.latest_version);
|
||||
}
|
||||
}
|
||||
|
||||
setPlugins(
|
||||
pluginsResp.plugins.map((plugin) => {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const compositeKey = `${author}/${name}`;
|
||||
const installedVersion = meta.version ?? '';
|
||||
|
||||
let hasUpdate = false;
|
||||
if (plugin.install_source === 'marketplace') {
|
||||
const latestVersion = marketplaceVersions.get(compositeKey);
|
||||
if (latestVersion) {
|
||||
hasUpdate = isNewerVersion(latestVersion, installedVersion);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: compositeKey,
|
||||
name: extractI18nObject(meta.label),
|
||||
iconURL: httpClient.getPluginIconURL(author, name),
|
||||
installSource: plugin.install_source,
|
||||
installInfo: plugin.install_info,
|
||||
hasUpdate,
|
||||
debug: plugin.debug,
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugins for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshMCPServers = useCallback(async () => {
|
||||
try {
|
||||
const resp = await httpClient.getMCPServers();
|
||||
setMCPServers(
|
||||
resp.servers.map((server) => ({
|
||||
id: server.name,
|
||||
name: server.name,
|
||||
enabled: server.enable,
|
||||
runtimeStatus: server.runtime_info?.status,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch MCP servers for sidebar:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await Promise.all([
|
||||
refreshBots(),
|
||||
refreshPipelines(),
|
||||
refreshKnowledgeBases(),
|
||||
refreshPlugins(),
|
||||
refreshMCPServers(),
|
||||
]);
|
||||
}, [
|
||||
refreshBots,
|
||||
refreshPipelines,
|
||||
refreshKnowledgeBases,
|
||||
refreshPlugins,
|
||||
refreshMCPServers,
|
||||
]);
|
||||
|
||||
// Fetch all entity lists on mount
|
||||
useEffect(() => {
|
||||
refreshAll();
|
||||
}, [refreshAll]);
|
||||
|
||||
return (
|
||||
<SidebarDataContext.Provider
|
||||
value={{
|
||||
bots,
|
||||
pipelines,
|
||||
knowledgeBases,
|
||||
plugins,
|
||||
mcpServers,
|
||||
refreshBots,
|
||||
refreshPipelines,
|
||||
refreshKnowledgeBases,
|
||||
refreshPlugins,
|
||||
refreshMCPServers,
|
||||
refreshAll,
|
||||
detailEntityName,
|
||||
setDetailEntityName,
|
||||
pendingPluginInstallAction,
|
||||
setPendingPluginInstallAction,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SidebarDataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSidebarData(): SidebarDataContextValue {
|
||||
const ctx = useContext(SidebarDataContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useSidebarData must be used within a SidebarDataProvider');
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
||||
import styles from './HomeSidebar.module.css';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
const t = (key: string) => {
|
||||
@@ -7,57 +6,39 @@ const t = (key: string) => {
|
||||
};
|
||||
|
||||
export const sidebarConfigList = [
|
||||
// ── Quick Start ──
|
||||
new SidebarChildVO({
|
||||
id: 'bots',
|
||||
name: t('bots.title'),
|
||||
id: 'wizard',
|
||||
name: t('sidebar.quickStart'),
|
||||
icon: (
|
||||
<svg
|
||||
className={`${styles.sidebarChildIcon}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M13.5 2C13.5 2.44425 13.3069 2.84339 13 3.11805V5H18C19.6569 5 21 6.34315 21 8V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V8C3 6.34315 4.34315 5 6 5H11V3.11805C10.6931 2.84339 10.5 2.44425 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2ZM6 7C5.44772 7 5 7.44772 5 8V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V8C19 7.44772 18.5523 7 18 7H13H11H6ZM2 10H0V16H2V10ZM22 10H24V16H22V10ZM9 14.5C9.82843 14.5 10.5 13.8284 10.5 13C10.5 12.1716 9.82843 11.5 9 11.5C8.17157 11.5 7.5 12.1716 7.5 13C7.5 13.8284 8.17157 14.5 9 14.5ZM15 14.5C15.8284 14.5 16.5 13.8284 16.5 13C16.5 12.1716 15.8284 11.5 15 11.5C14.1716 11.5 13.5 12.1716 13.5 13C13.5 13.8284 14.1716 14.5 15 14.5Z"></path>
|
||||
<path d="M13 9H21L11 24V15H4L13 0V9ZM11 11V7.22063L7.53238 13H13V17.3944L17.263 11H11Z"></path>
|
||||
</svg>
|
||||
),
|
||||
route: '/home/bots',
|
||||
description: t('bots.description'),
|
||||
route: '/wizard',
|
||||
description: t('wizard.sidebarDescription'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/usage/platforms/readme',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme',
|
||||
},
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'pipelines',
|
||||
name: t('pipelines.title'),
|
||||
icon: (
|
||||
<svg
|
||||
className={`${styles.sidebarChildIcon}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M6 21.5C4.067 21.5 2.5 19.933 2.5 18C2.5 16.067 4.067 14.5 6 14.5C7.5852 14.5 8.92427 15.5539 9.35481 16.9992L15 16.9994V15L17 14.9994V9.24339L14.757 6.99938H9V9.00003H3V3.00003H9V4.99939H14.757L18 1.75739L22.2426 6.00003L19 9.24139V14.9994L21 15V21H15V18.9994L9.35499 19.0003C8.92464 20.4459 7.58543 21.5 6 21.5ZM6 16.5C5.17157 16.5 4.5 17.1716 4.5 18C4.5 18.8285 5.17157 19.5 6 19.5C6.82843 19.5 7.5 18.8285 7.5 18C7.5 17.1716 6.82843 16.5 6 16.5ZM19 17H17V19H19V17ZM18 4.58581L16.5858 6.00003L18 7.41424L19.4142 6.00003L18 4.58581ZM7 5.00003H5V7.00003H7V5.00003Z"></path>
|
||||
</svg>
|
||||
),
|
||||
route: '/home/pipelines',
|
||||
description: t('pipelines.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/usage/pipelines/readme',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme',
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
section: 'standalone',
|
||||
}),
|
||||
|
||||
// ── Home section ──
|
||||
new SidebarChildVO({
|
||||
id: 'monitoring',
|
||||
name: t('monitoring.title'),
|
||||
icon: (
|
||||
<svg
|
||||
className={`${styles.sidebarChildIcon}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M2 3.9934C2 3.44476 2.45531 3 2.9918 3H21.0082C21.556 3 22 3.44495 22 3.9934V20.0066C22 20.5552 21.5447 21 21.0082 21H2.9918C2.44405 21 2 20.5551 2 20.0066V3.9934ZM4 5V19H20V5H4ZM6 7H18V9H6V7ZM6 11H18V13H6V11ZM6 15H12V17H6V15Z"></path>
|
||||
</svg>
|
||||
@@ -68,6 +49,51 @@ export const sidebarConfigList = [
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'bots',
|
||||
name: t('bots.title'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M13.5 2C13.5 2.44425 13.3069 2.84339 13 3.11805V5H18C19.6569 5 21 6.34315 21 8V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V8C3 6.34315 4.34315 5 6 5H11V3.11805C10.6931 2.84339 10.5 2.44425 10.5 2C10.5 1.17157 11.1716 0.5 12 0.5C12.8284 0.5 13.5 1.17157 13.5 2ZM6 7C5.44772 7 5 7.44772 5 8V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V8C19 7.44772 18.5523 7 18 7H13H11H6ZM2 10H0V16H2V10ZM22 10H24V16H22V10ZM9 14.5C9.82843 14.5 10.5 13.8284 10.5 13C10.5 12.1716 9.82843 11.5 9 11.5C8.17157 11.5 7.5 12.1716 7.5 13C7.5 13.8284 8.17157 14.5 9 14.5ZM15 14.5C15.8284 14.5 16.5 13.8284 16.5 13C16.5 12.1716 15.8284 11.5 15 11.5C14.1716 11.5 13.5 12.1716 13.5 13C13.5 13.8284 14.1716 14.5 15 14.5Z"></path>
|
||||
</svg>
|
||||
),
|
||||
route: '/home/bots',
|
||||
description: t('bots.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/platforms',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/platforms',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/platforms',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'pipelines',
|
||||
name: t('pipelines.title'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M6 21.5C4.067 21.5 2.5 19.933 2.5 18C2.5 16.067 4.067 14.5 6 14.5C7.5852 14.5 8.92427 15.5539 9.35481 16.9992L15 16.9994V15L17 14.9994V9.24339L14.757 6.99938H9V9.00003H3V3.00003H9V4.99939H14.757L18 1.75739L22.2426 6.00003L19 9.24139V14.9994L21 15V21H15V18.9994L9.35499 19.0003C8.92464 20.4459 7.58543 21.5 6 21.5ZM6 16.5C5.17157 16.5 4.5 17.1716 4.5 18C4.5 18.8285 5.17157 19.5 6 19.5C6.82843 19.5 7.5 18.8285 7.5 18C7.5 17.1716 6.82843 16.5 6 16.5ZM19 17H17V19H19V17ZM18 4.58581L16.5858 6.00003L18 7.41424L19.4142 6.00003L18 4.58581ZM7 5.00003H5V7.00003H7V5.00003Z"></path>
|
||||
</svg>
|
||||
),
|
||||
route: '/home/pipelines',
|
||||
description: t('pipelines.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/pipelines',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/pipelines',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/pipelines',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'knowledge',
|
||||
@@ -77,6 +103,7 @@ export const sidebarConfigList = [
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M3 18.5V5C3 3.34315 4.34315 2 6 2H20C20.5523 2 21 2.44772 21 3V21C21 21.5523 20.5523 22 20 22H6.5C4.567 22 3 20.433 3 18.5ZM19 20V17H6.5C5.67157 17 5 17.6716 5 18.5C5 19.3284 5.67157 20 6.5 20H19ZM10 4H6C5.44772 4 5 4.44772 5 5V15.3368C5.45463 15.1208 5.9632 15 6.5 15H19V4H17V12L13.5 10L10 12V4Z"></path>
|
||||
</svg>
|
||||
@@ -84,20 +111,23 @@ export const sidebarConfigList = [
|
||||
route: '/home/knowledge',
|
||||
description: t('knowledge.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/usage/knowledge/readme',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme',
|
||||
en_US: 'https://link.langbot.app/en/docs/knowledge',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/knowledge',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/knowledge',
|
||||
},
|
||||
section: 'home',
|
||||
}),
|
||||
|
||||
// ── Extensions section ──
|
||||
new SidebarChildVO({
|
||||
id: 'plugins',
|
||||
name: t('plugins.title'),
|
||||
name: t('sidebar.installedPlugins'),
|
||||
icon: (
|
||||
<svg
|
||||
className={`${styles.sidebarChildIcon}`}
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M7 5C7 2.79086 8.79086 1 11 1C13.2091 1 15 2.79086 15 5H18C18.5523 5 19 5.44772 19 6V9C21.2091 9 23 10.7909 23 13C23 15.2091 21.2091 17 19 17V20C19 20.5523 18.5523 21 18 21H4C3.44772 21 3 20.5523 3 20V6C3 5.44772 3.44772 5 4 5H7ZM11 3C9.89543 3 9 3.89543 9 5C9 5.23554 9.0403 5.45952 9.11355 5.66675C9.22172 5.97282 9.17461 6.31235 8.98718 6.57739C8.79974 6.84243 8.49532 7 8.17071 7H5V19H17V15.8293C17 15.5047 17.1576 15.2003 17.4226 15.0128C17.6877 14.8254 18.0272 14.7783 18.3332 14.8865C18.5405 14.9597 18.7645 15 19 15C20.1046 15 21 14.1046 21 13C21 11.8954 20.1046 11 19 11C18.7645 11 18.5405 11.0403 18.3332 11.1135C18.0272 11.2217 17.6877 11.1746 17.4226 10.9872C17.1576 10.7997 17 10.4953 17 10.1707V7H13.8293C13.5047 7 13.2003 6.84243 13.0128 6.57739C12.8254 6.31235 12.7783 5.97282 12.8865 5.66675C12.9597 5.45952 13 5.23555 13 5C13 3.89543 12.1046 3 11 3Z"></path>
|
||||
</svg>
|
||||
@@ -105,9 +135,53 @@ export const sidebarConfigList = [
|
||||
route: '/home/plugins',
|
||||
description: t('plugins.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/usage/plugin/plugin-intro',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro',
|
||||
en_US: 'https://link.langbot.app/en/docs/plugins',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/plugins',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/plugins',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'market',
|
||||
name: t('sidebar.pluginMarket'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M21 13.242V20H22V22H2V20H3V13.242C1.79401 12.435 1 11.0602 1 9.5C1 8.67286 1.25027 7.90335 1.67755 7.2612L4.5547 2.36088C4.80513 1.93859 5.26028 1.67578 5.76 1.67578H18.24C18.7397 1.67578 19.1949 1.93859 19.4453 2.36088L22.3225 7.2612C22.7497 7.90335 23 8.67286 23 9.5C23 11.0602 22.206 12.435 21 13.242ZM19 13.972C18.4511 14.0706 17.8794 14.0706 17.3305 13.972C16.1644 13.7566 15.1377 13.0712 14.5 12.1C13.8623 13.0712 12.8356 13.7566 11.6695 13.972C11.1206 14.0706 10.5489 14.0706 10 13.972C9.45108 14.0706 8.87938 14.0706 8.33053 13.972C7.16437 13.7566 6.13771 13.0712 5.5 12.1C4.86229 13.0712 3.83563 13.7566 2.66947 13.972C2.44883 14.0124 2.22434 14.0352 2 14.0404V20H5V15H10V20H19V13.972Z"></path>
|
||||
</svg>
|
||||
),
|
||||
route: '/home/market',
|
||||
description: t('plugins.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://link.langbot.app/en/docs/plugins',
|
||||
zh_Hans: 'https://link.langbot.app/zh/docs/plugins',
|
||||
ja_JP: 'https://link.langbot.app/ja/docs/plugins',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
id: 'mcp',
|
||||
name: t('sidebar.mcpServers'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="text-blue-500"
|
||||
>
|
||||
<path d="M4.5 7.65311V16.3469L12 20.689L19.5 16.3469V7.65311L12 3.311L4.5 7.65311ZM12 1L21.5 6.5V17.5L12 23L2.5 17.5V6.5L12 1ZM6.49896 9.97065L11 12.5765V17.625H13V12.5765L17.501 9.97066L16.499 8.2398L12 10.8445L7.50104 8.2398L6.49896 9.97065Z"></path>
|
||||
</svg>
|
||||
),
|
||||
route: '/home/mcp',
|
||||
description: t('mcp.title'),
|
||||
helpLink: {
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
section: 'extensions',
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import styles from './HomeTittleBar.module.css';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
|
||||
export default function HomeTitleBar({
|
||||
title,
|
||||
subtitle,
|
||||
helpLink,
|
||||
}: {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
helpLink: I18nObject;
|
||||
}) {
|
||||
return (
|
||||
<div className={`${styles.titleBarContainer}`}>
|
||||
<div className={`${styles.titleText}`}>{title}</div>
|
||||
<div className={`${styles.subtitleText}`}>
|
||||
{subtitle}
|
||||
<span className={`${styles.helpLink}`}>
|
||||
<div
|
||||
onClick={() => {
|
||||
window.open(extractI18nObject(helpLink), '_blank');
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
className="w-[1rem] h-[1rem]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11 15H13V17H11V15ZM13 13.3551V14H11V12.5C11 11.9477 11.4477 11.5 12 11.5C12.8284 11.5 13.5 10.8284 13.5 10C13.5 9.17157 12.8284 8.5 12 8.5C11.2723 8.5 10.6656 9.01823 10.5288 9.70577L8.56731 9.31346C8.88637 7.70919 10.302 6.5 12 6.5C13.933 6.5 15.5 8.067 15.5 10C15.5 11.5855 14.4457 12.9248 13 13.3551Z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
.titleBarContainer {
|
||||
width: 100%;
|
||||
padding-top: 1rem;
|
||||
height: 4rem;
|
||||
opacity: 1;
|
||||
font-size: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.titleText {
|
||||
margin-left: 3.2rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 500;
|
||||
color: #585858;
|
||||
}
|
||||
|
||||
:global(.dark) .titleText {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
.subtitleText {
|
||||
margin-left: 3.2rem;
|
||||
font-size: 0.8rem;
|
||||
color: #808080;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
:global(.dark) .subtitleText {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
.helpLink {
|
||||
margin-left: 0.2rem;
|
||||
font-size: 0.8rem;
|
||||
color: #8b8b8b;
|
||||
}
|
||||
|
||||
:global(.dark) .helpLink {
|
||||
color: #a0a0a0;
|
||||
}
|
||||
@@ -36,11 +36,11 @@ export default function NewVersionDialog({
|
||||
const getUpdateDocsUrl = () => {
|
||||
const language = i18n.language;
|
||||
if (language === 'zh-Hans' || language === 'zh-Hant') {
|
||||
return 'https://docs.langbot.app/zh/deploy/update';
|
||||
return 'https://link.langbot.app/zh/docs/update';
|
||||
} else if (language === 'ja-JP') {
|
||||
return 'https://docs.langbot.app/ja/deploy/update';
|
||||
return 'https://link.langbot.app/ja/docs/update';
|
||||
} else {
|
||||
return 'https://docs.langbot.app/en/deploy/update';
|
||||
return 'https://link.langbot.app/en/docs/update';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
279
web/src/app/home/knowledge/KBDetailContent.tsx
Normal file
279
web/src/app/home/knowledge/KBDetailContent.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
|
||||
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
|
||||
import KBRetrieveGeneric from '@/app/home/knowledge/components/kb-retrieve/KBRetrieveGeneric';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { toast } from 'sonner';
|
||||
import { FileText, FolderOpen, Search, Trash2 } from 'lucide-react';
|
||||
|
||||
export default function KBDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { refreshKnowledgeBases, knowledgeBases, setDetailEntityName } =
|
||||
useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
setDetailEntityName(t('knowledge.createKnowledgeBase'));
|
||||
} else {
|
||||
const kb = knowledgeBases.find((k) => k.id === id);
|
||||
setDetailEntityName(kb?.name ?? id);
|
||||
}
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, knowledgeBases, setDetailEntityName, t]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState('metadata');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
|
||||
const loadKbInfo = useCallback(
|
||||
async (kbId: string) => {
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeBase(kbId);
|
||||
setKbInfo(resp.base);
|
||||
} catch (e) {
|
||||
console.error('Failed to load KB info:', e);
|
||||
toast.error(
|
||||
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
||||
);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// Load KB info for determining capabilities (e.g. doc_ingestion)
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) {
|
||||
loadKbInfo(id);
|
||||
}
|
||||
}, [id, isCreateMode, loadKbInfo]);
|
||||
|
||||
const hasDocumentCapability = (): boolean => {
|
||||
if (!kbInfo || !kbInfo.knowledge_engine) return false;
|
||||
return (
|
||||
kbInfo.knowledge_engine.capabilities?.includes('doc_ingestion') ?? false
|
||||
);
|
||||
};
|
||||
|
||||
function handleKbDeleted() {
|
||||
refreshKnowledgeBases();
|
||||
router.push('/home/knowledge');
|
||||
}
|
||||
|
||||
function handleNewKbCreated(newKbId: string) {
|
||||
refreshKnowledgeBases();
|
||||
router.push(`/home/knowledge?id=${encodeURIComponent(newKbId)}`);
|
||||
}
|
||||
|
||||
function handleKbUpdated() {
|
||||
refreshKnowledgeBases();
|
||||
loadKbInfo(id);
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
try {
|
||||
await httpClient.deleteKnowledgeBase(id);
|
||||
setShowDeleteConfirm(false);
|
||||
handleKbDeleted();
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
t('knowledge.deleteKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const retrieveFunction = async (kbId: string, query: string) => {
|
||||
return await httpClient.retrieveKnowledgeBase(kbId, query);
|
||||
};
|
||||
|
||||
// ==================== Create Mode ====================
|
||||
if (isCreateMode) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('knowledge.createKnowledgeBase')}
|
||||
</h1>
|
||||
<Button type="submit" form="kb-form">
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl pb-8">
|
||||
<KBForm
|
||||
initKbId={undefined}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Sticky Header: title + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('knowledge.editKnowledgeBase')}
|
||||
</h1>
|
||||
<Button type="submit" form="kb-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
<Tabs
|
||||
key={id}
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-1 flex-col min-h-0"
|
||||
>
|
||||
<TabsList className="shrink-0">
|
||||
<TabsTrigger value="metadata" className="gap-1.5">
|
||||
<FileText className="size-3.5" />
|
||||
{t('knowledge.metadata')}
|
||||
</TabsTrigger>
|
||||
{hasDocumentCapability() && (
|
||||
<TabsTrigger value="documents" className="gap-1.5">
|
||||
<FolderOpen className="size-3.5" />
|
||||
{t('knowledge.documents')}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="retrieve" className="gap-1.5">
|
||||
<Search className="size-3.5" />
|
||||
{t('knowledge.retrieve')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Tab: Metadata */}
|
||||
<TabsContent
|
||||
value="metadata"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<KBForm
|
||||
initKbId={id}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
onDirtyChange={setFormDirty}
|
||||
/>
|
||||
|
||||
{/* Danger Zone Card */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('knowledge.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('knowledge.deleteKbAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('knowledge.deleteKbHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Tab: Documents */}
|
||||
{hasDocumentCapability() && (
|
||||
<TabsContent
|
||||
value="documents"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<KBDoc
|
||||
kbId={id}
|
||||
ragEngineName={kbInfo?.knowledge_engine?.name}
|
||||
ragEngineCapabilities={kbInfo?.knowledge_engine?.capabilities}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Tab: Retrieve */}
|
||||
<TabsContent
|
||||
value="retrieve"
|
||||
className="flex-1 min-h-0 overflow-y-auto mt-4"
|
||||
>
|
||||
<KBRetrieveGeneric kbId={id} retrieveFunction={retrieveFunction} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('knowledge.deleteKnowledgeBaseConfirmation')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{t('knowledge.deleteKnowledgeBaseConfirmation')}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarGroup,
|
||||
SidebarGroupContent,
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
SidebarProvider,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { toast } from 'sonner';
|
||||
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
|
||||
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
|
||||
import KBRetrieveGeneric from '@/app/home/knowledge/components/kb-retrieve/KBRetrieveGeneric';
|
||||
|
||||
interface KBDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
kbId?: string;
|
||||
onFormCancel: () => void;
|
||||
onKbDeleted: () => void;
|
||||
onNewKbCreated: (kbId: string) => void;
|
||||
onKbUpdated: (kbId: string) => void;
|
||||
}
|
||||
|
||||
export default function KBDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
kbId: propKbId,
|
||||
onFormCancel,
|
||||
onKbDeleted,
|
||||
onNewKbCreated,
|
||||
onKbUpdated,
|
||||
}: KBDetailDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [kbId, setKbId] = useState<string | undefined>(propKbId);
|
||||
const [activeMenu, setActiveMenu] = useState('metadata');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setKbId(propKbId);
|
||||
setActiveMenu('metadata');
|
||||
if (propKbId) {
|
||||
loadKbInfo(propKbId);
|
||||
} else {
|
||||
setKbInfo(null);
|
||||
}
|
||||
}, [propKbId, open]);
|
||||
|
||||
async function loadKbInfo(id: string) {
|
||||
try {
|
||||
const resp = await httpClient.getKnowledgeBase(id);
|
||||
setKbInfo(resp.base);
|
||||
} catch (e) {
|
||||
console.error('Failed to load KB info:', e);
|
||||
toast.error(
|
||||
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this KB supports document management
|
||||
const hasDocumentCapability = (): boolean => {
|
||||
if (!kbInfo || !kbInfo.knowledge_engine) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
kbInfo.knowledge_engine.capabilities?.includes('doc_ingestion') ?? false
|
||||
);
|
||||
};
|
||||
|
||||
// Build menu based on KB capabilities
|
||||
const menu = [
|
||||
{
|
||||
key: 'metadata',
|
||||
label: t('knowledge.metadata'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M5 7C5 6.17157 5.67157 5.5 6.5 5.5C7.32843 5.5 8 6.17157 8 7C8 7.82843 7.32843 8.5 6.5 8.5C5.67157 8.5 5 7.82843 5 7ZM6.5 3.5C4.567 3.5 3 5.067 3 7C3 8.933 4.567 10.5 6.5 10.5C8.433 10.5 10 8.933 10 7C10 5.067 8.433 3.5 6.5 3.5ZM12 8H20V6H12V8ZM16 17C16 16.1716 16.6716 15.5 17.5 15.5C18.3284 15.5 19 16.1716 19 17C19 17.8284 18.3284 18.5 17.5 18.5C16.6716 18.5 16 17.8284 16 17ZM17.5 13.5C15.567 13.5 14 15.067 14 17C14 18.933 15.567 20.5 17.5 20.5C19.433 20.5 21 18.933 21 17C21 15.067 19.433 13.5 17.5 13.5ZM4 16V18H12V16H4Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
// Show documents only if capability is present
|
||||
...(hasDocumentCapability()
|
||||
? [
|
||||
{
|
||||
key: 'documents',
|
||||
label: t('knowledge.documents'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M21 8V20.9932C21 21.5501 20.5552 22 20.0066 22H3.9934C3.44495 22 3 21.556 3 21.0082V2.9918C3 2.45531 3.4487 2 4.00221 2H14.9968L21 8ZM19 9H14V4H5V20H19V9ZM8 7H11V9H8V7ZM8 11H16V13H8V11ZM8 15H16V17H8V15Z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'retrieve',
|
||||
label: t('knowledge.retrieve'),
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M18.031 16.617l4.283 4.282-1.415 1.415-4.282-4.283A8.96 8.96 0 0 1 11 20c-4.968 0-9-4.032-9-9s4.032-9 9-9 9 4.032 9 9a8.96 8.96 0 0 1-1.969 5.617zm-2.006-.742A6.977 6.977 0 0 0 18 11c0-3.868-3.133-7-7-7-3.868 0-7 3.132-7 7 0 3.867 3.132 7 7 7a6.977 6.977 0 0 0 4.875-1.975l.15-.15z"></path>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await httpClient.deleteKnowledgeBase(kbId ?? '');
|
||||
onKbDeleted();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete KB:', e);
|
||||
toast.error(
|
||||
t('knowledge.deleteKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
||||
);
|
||||
} finally {
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Retrieve function
|
||||
const retrieveFunction = async (id: string, query: string) => {
|
||||
return await httpClient.retrieveKnowledgeBase(id, query);
|
||||
};
|
||||
|
||||
if (!kbId) {
|
||||
// New KB creation
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 !max-w-[40vw] max-h-[70vh] flex">
|
||||
<main className="flex flex-1 flex-col h-[70vh]">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle>{t('knowledge.createKnowledgeBase')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<KBForm
|
||||
initKbId={undefined}
|
||||
onNewKbCreated={onNewKbCreated}
|
||||
onKbUpdated={onKbUpdated}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="px-6 py-4 border-t shrink-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="submit" form="kb-form">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onFormCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</main>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="overflow-hidden p-0 !max-w-[50rem] max-h-[75vh] flex">
|
||||
<SidebarProvider className="items-start w-full flex">
|
||||
<Sidebar
|
||||
collapsible="none"
|
||||
className="hidden md:flex h-[80vh] w-40 min-w-[120px] border-r bg-white dark:bg-black"
|
||||
>
|
||||
<SidebarContent>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{menu.map((item) => (
|
||||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={activeMenu === item.key}
|
||||
onClick={() => setActiveMenu(item.key)}
|
||||
>
|
||||
<a href="#">
|
||||
{item.icon}
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
<main className="flex flex-1 flex-col h-[75vh] min-w-0 overflow-x-hidden">
|
||||
<DialogHeader className="px-6 pt-6 pb-4 shrink-0">
|
||||
<DialogTitle>
|
||||
{activeMenu === 'metadata'
|
||||
? t('knowledge.editKnowledgeBase')
|
||||
: activeMenu === 'documents'
|
||||
? t('knowledge.editDocument')
|
||||
: t('knowledge.retrieveTest')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
{activeMenu === 'metadata' && (
|
||||
<KBForm
|
||||
initKbId={kbId}
|
||||
onNewKbCreated={onNewKbCreated}
|
||||
onKbUpdated={onKbUpdated}
|
||||
/>
|
||||
)}
|
||||
{activeMenu === 'documents' && hasDocumentCapability() && (
|
||||
<KBDoc
|
||||
kbId={kbId}
|
||||
ragEngineName={kbInfo?.knowledge_engine?.name}
|
||||
ragEngineCapabilities={
|
||||
kbInfo?.knowledge_engine?.capabilities
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{activeMenu === 'retrieve' && (
|
||||
<KBRetrieveGeneric
|
||||
kbId={kbId}
|
||||
retrieveFunction={retrieveFunction}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{activeMenu === 'metadata' && (
|
||||
<DialogFooter className="px-6 py-4 border-t shrink-0">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
<Button type="submit" form="kb-form">
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onFormCancel}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</main>
|
||||
</SidebarProvider>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.confirmDelete')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
{t('knowledge.deleteKnowledgeBaseConfirmation')}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
height: 10rem;
|
||||
background-color: #fff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid #e4e4e7;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
@@ -15,15 +15,15 @@
|
||||
|
||||
:global(.dark) .cardContainer {
|
||||
background-color: #1f1f22;
|
||||
box-shadow: 0;
|
||||
border-color: #27272a;
|
||||
}
|
||||
|
||||
.cardContainer:hover {
|
||||
box-shadow: 0px 2px 8px 0 rgba(0, 0, 0, 0.1);
|
||||
border-color: #a1a1aa;
|
||||
}
|
||||
|
||||
:global(.dark) .cardContainer:hover {
|
||||
box-shadow: 0;
|
||||
border-color: #3f3f46;
|
||||
}
|
||||
|
||||
.basicInfoContainer {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
@@ -15,6 +15,13 @@ import {
|
||||
FormMessage,
|
||||
FormDescription,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
Select,
|
||||
@@ -39,9 +46,7 @@ import { UUID } from 'uuidjs';
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z.object({
|
||||
name: z.string().min(1, { message: t('knowledge.kbNameRequired') }),
|
||||
description: z
|
||||
.string()
|
||||
.min(1, { message: t('knowledge.kbDescriptionRequired') }),
|
||||
description: z.string().optional(),
|
||||
emoji: z.string().optional(),
|
||||
ragEngineId: z
|
||||
.string()
|
||||
@@ -50,17 +55,13 @@ const getFormSchema = (t: (key: string) => string) =>
|
||||
|
||||
/**
|
||||
* Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[]
|
||||
* Same pattern as ExternalKBForm uses for retriever config
|
||||
*/
|
||||
function parseCreationSchema(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
schemaItems: any | any[] | undefined,
|
||||
): IDynamicFormItemSchema[] {
|
||||
if (!schemaItems) return [];
|
||||
|
||||
// Handle wrapped schema (e.g. { schema: [...] }) which might be returned by the API
|
||||
const items = Array.isArray(schemaItems) ? schemaItems : schemaItems.schema;
|
||||
|
||||
if (!items || !Array.isArray(items)) return [];
|
||||
|
||||
return items.map(
|
||||
@@ -83,10 +84,12 @@ export default function KBForm({
|
||||
initKbId,
|
||||
onNewKbCreated,
|
||||
onKbUpdated,
|
||||
onDirtyChange,
|
||||
}: {
|
||||
initKbId?: string;
|
||||
onNewKbCreated: (kbId: string) => void;
|
||||
onKbUpdated: (kbId: string) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [ragEngines, setRagEngines] = useState<KnowledgeEngine[]>([]);
|
||||
@@ -100,13 +103,17 @@ export default function KBForm({
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Dirty tracking: snapshot of saved state for comparison
|
||||
const savedSnapshotRef = useRef<string>('');
|
||||
const isInitializing = useRef(true);
|
||||
|
||||
const formSchema = getFormSchema(t);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
name: '',
|
||||
description: t('knowledge.defaultDescription'),
|
||||
description: '',
|
||||
emoji: '📚',
|
||||
ragEngineId: '',
|
||||
},
|
||||
@@ -117,6 +124,27 @@ export default function KBForm({
|
||||
(e) => e.plugin_id === selectedEngineId,
|
||||
);
|
||||
|
||||
// Dirty tracking: compare current form + dynamic settings against saved snapshot
|
||||
const watchedFormValues = form.watch();
|
||||
useEffect(() => {
|
||||
if (!savedSnapshotRef.current || isInitializing.current) return;
|
||||
const currentSnapshot = JSON.stringify({
|
||||
form: watchedFormValues,
|
||||
config: configSettings,
|
||||
retrieval: retrievalSettings,
|
||||
});
|
||||
const dirty = currentSnapshot !== savedSnapshotRef.current;
|
||||
onDirtyChange?.(dirty);
|
||||
}, [watchedFormValues, configSettings, retrievalSettings, onDirtyChange]);
|
||||
|
||||
const captureSnapshot = () => {
|
||||
savedSnapshotRef.current = JSON.stringify({
|
||||
form: form.getValues(),
|
||||
config: configSettings,
|
||||
retrieval: retrievalSettings,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadRagEngines().then(() => {
|
||||
if (initKbId) {
|
||||
@@ -131,7 +159,6 @@ export default function KBForm({
|
||||
const firstEngine = ragEngines[0];
|
||||
setSelectedEngineId(firstEngine.plugin_id);
|
||||
form.setValue('ragEngineId', firstEngine.plugin_id);
|
||||
// Initialize config settings with defaults
|
||||
const formItems = parseCreationSchema(firstEngine.creation_schema);
|
||||
if (formItems.length > 0) {
|
||||
setConfigSettings(getDefaultValues(formItems));
|
||||
@@ -157,6 +184,7 @@ export default function KBForm({
|
||||
|
||||
const loadKbConfig = async (kbId: string) => {
|
||||
try {
|
||||
isInitializing.current = true;
|
||||
setIsEditing(true);
|
||||
|
||||
const res = await httpClient.getKnowledgeBase(kbId);
|
||||
@@ -165,15 +193,24 @@ export default function KBForm({
|
||||
const engineId = kb.knowledge_engine_plugin_id || '';
|
||||
setSelectedEngineId(engineId);
|
||||
|
||||
form.setValue('name', kb.name);
|
||||
form.setValue('description', kb.description);
|
||||
form.setValue('emoji', kb.emoji || '📚');
|
||||
form.setValue('ragEngineId', engineId);
|
||||
form.reset({
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
emoji: kb.emoji || '📚',
|
||||
ragEngineId: engineId,
|
||||
});
|
||||
|
||||
setConfigSettings(kb.creation_settings || {});
|
||||
setRetrievalSettings(kb.retrieval_settings || {});
|
||||
|
||||
// Capture snapshot after a tick so dynamic forms have emitted initial values
|
||||
setTimeout(() => {
|
||||
captureSnapshot();
|
||||
isInitializing.current = false;
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
console.error('Failed to load KB config:', err);
|
||||
isInitializing.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -181,7 +218,6 @@ export default function KBForm({
|
||||
setSelectedEngineId(engineId);
|
||||
form.setValue('ragEngineId', engineId);
|
||||
|
||||
// Find engine and initialize config settings with defaults from schema
|
||||
const engine = ragEngines.find((e) => e.plugin_id === engineId);
|
||||
if (engine) {
|
||||
const formItems = parseCreationSchema(engine.creation_schema);
|
||||
@@ -202,7 +238,7 @@ export default function KBForm({
|
||||
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
||||
const kbData: KnowledgeBase = {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
description: data.description ?? '',
|
||||
emoji: data.emoji,
|
||||
knowledge_engine_plugin_id: selectedEngineId,
|
||||
creation_settings: configSettings,
|
||||
@@ -210,10 +246,11 @@ export default function KBForm({
|
||||
};
|
||||
|
||||
if (initKbId) {
|
||||
// Update knowledge base
|
||||
httpClient
|
||||
.updateKnowledgeBase(initKbId, kbData)
|
||||
.then((res) => {
|
||||
captureSnapshot();
|
||||
onDirtyChange?.(false);
|
||||
onKbUpdated(res.uuid);
|
||||
toast.success(t('knowledge.updateKnowledgeBaseSuccess'));
|
||||
})
|
||||
@@ -225,7 +262,6 @@ export default function KBForm({
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Create knowledge base
|
||||
httpClient
|
||||
.createKnowledgeBase(kbData)
|
||||
.then((res) => {
|
||||
@@ -241,15 +277,11 @@ export default function KBForm({
|
||||
}
|
||||
};
|
||||
|
||||
// Convert creation schema to dynamic form items (same as ExternalKBForm)
|
||||
// Memoize to avoid regenerating UUIDs on every render, which would cause
|
||||
// DynamicFormComponent's useEffect to re-fire and trigger an infinite loop.
|
||||
const configFormItems = useMemo(
|
||||
() => parseCreationSchema(selectedEngine?.creation_schema),
|
||||
[selectedEngine?.creation_schema],
|
||||
);
|
||||
|
||||
// Convert retrieval schema to dynamic form items
|
||||
const retrievalFormItems = useMemo(
|
||||
() => parseCreationSchema(selectedEngine?.retrieval_schema),
|
||||
[selectedEngine?.retrieval_schema],
|
||||
@@ -272,7 +304,7 @@ export default function KBForm({
|
||||
{t('knowledge.noEnginesAvailable')}
|
||||
</p>
|
||||
<Link
|
||||
href="/home/plugins"
|
||||
href="/home/market?category=KnowledgeEngine"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t('knowledge.installEngineHint')}
|
||||
@@ -282,65 +314,21 @@ export default function KBForm({
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="kb-form"
|
||||
className="space-y-8"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Knowledge Engine Selector */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ragEngineId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('knowledge.knowledgeEngine')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
disabled={isEditing}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleEngineChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue
|
||||
placeholder={t('knowledge.selectKnowledgeEngine')}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="fixed z-[1000]">
|
||||
{ragEngines.map((engine) => (
|
||||
<SelectItem
|
||||
key={engine.plugin_id}
|
||||
value={engine.plugin_id}
|
||||
>
|
||||
{extractI18nObject(engine.name)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedEngine?.description && (
|
||||
<FormDescription>
|
||||
{extractI18nObject(selectedEngine.description)}
|
||||
</FormDescription>
|
||||
)}
|
||||
{isEditing && (
|
||||
<FormDescription>
|
||||
{t('knowledge.cannotChangeKnowledgeEngine')}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
id="kb-form"
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Card 1: Basic Information */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('knowledge.basicInfo')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.basicInfoDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Name and Emoji in same row */}
|
||||
<div className="flex gap-4 items-start">
|
||||
<FormField
|
||||
@@ -350,7 +338,7 @@ export default function KBForm({
|
||||
<FormItem className="flex-1">
|
||||
<FormLabel>
|
||||
{t('knowledge.kbName')}
|
||||
<span className="text-red-500">*</span>
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
@@ -383,10 +371,7 @@ export default function KBForm({
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('knowledge.kbDescription')}
|
||||
<span className="text-red-500">*</span>
|
||||
</FormLabel>
|
||||
<FormLabel>{t('knowledge.kbDescription')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
@@ -395,47 +380,143 @@ export default function KBForm({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Engine specific fields (dynamic form from creation_schema) */}
|
||||
{configFormItems.length > 0 && (
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{t('knowledge.engineSettings')}
|
||||
</div>
|
||||
<div>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={configFormItems}
|
||||
initialValues={configSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setConfigSettings(val as Record<string, unknown>)
|
||||
}
|
||||
isEditing={isEditing}
|
||||
externalDependentValues={retrievalSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Knowledge Engine Selector */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ragEngineId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('knowledge.knowledgeEngine')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
disabled={isEditing}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value);
|
||||
handleEngineChange(value);
|
||||
}}
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-full bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
{field.value ? (
|
||||
(() => {
|
||||
const [author, name] = field.value.split('/');
|
||||
const engine = ragEngines.find(
|
||||
(e) => e.plugin_id === field.value,
|
||||
);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
author,
|
||||
name,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>
|
||||
{engine
|
||||
? extractI18nObject(engine.name)
|
||||
: field.value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<SelectValue
|
||||
placeholder={t('knowledge.selectKnowledgeEngine')}
|
||||
/>
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent className="fixed z-[1000]">
|
||||
{ragEngines.map((engine) => {
|
||||
const [author, name] = engine.plugin_id.split('/');
|
||||
return (
|
||||
<SelectItem
|
||||
key={engine.plugin_id}
|
||||
value={engine.plugin_id}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<img
|
||||
src={httpClient.getPluginIconURL(
|
||||
author,
|
||||
name,
|
||||
)}
|
||||
alt=""
|
||||
className="h-5 w-5 rounded"
|
||||
/>
|
||||
<span>{extractI18nObject(engine.name)}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{selectedEngine?.description && (
|
||||
<FormDescription>
|
||||
{extractI18nObject(selectedEngine.description)}
|
||||
</FormDescription>
|
||||
)}
|
||||
{isEditing && (
|
||||
<FormDescription>
|
||||
{t('knowledge.cannotChangeKnowledgeEngine')}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Retrieval settings (dynamic form from retrieval_schema) */}
|
||||
{retrievalFormItems.length > 0 && (
|
||||
<div className="space-y-4 pt-2 border-t">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
{t('knowledge.retrievalSettings')}
|
||||
</div>
|
||||
<div>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={retrievalFormItems}
|
||||
initialValues={retrievalSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setRetrievalSettings(val as Record<string, unknown>)
|
||||
}
|
||||
externalDependentValues={configSettings}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</>
|
||||
{/* Card 2: Engine Settings (dynamic form from creation_schema) */}
|
||||
{configFormItems.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('knowledge.engineSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.engineSettingsDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={configFormItems}
|
||||
initialValues={configSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setConfigSettings(val as Record<string, unknown>)
|
||||
}
|
||||
isEditing={isEditing}
|
||||
externalDependentValues={retrievalSettings}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Card 3: Retrieval Settings (dynamic form from retrieval_schema) */}
|
||||
{retrievalFormItems.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t('knowledge.retrievalSettings')}</CardTitle>
|
||||
<CardDescription>
|
||||
{t('knowledge.retrievalSettingsDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DynamicFormComponent
|
||||
itemConfigList={retrievalFormItems}
|
||||
initialValues={retrievalSettings as Record<string, object>}
|
||||
onSubmit={(val) =>
|
||||
setRetrievalSettings(val as Record<string, unknown>)
|
||||
}
|
||||
externalDependentValues={configSettings}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import CreateCardComponent from '@/app/infra/basic-component/create-card-component/CreateCardComponent';
|
||||
import styles from './knowledgeBase.module.css';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
|
||||
import KBCard from '@/app/home/knowledge/components/kb-card/KBCard';
|
||||
import KBDetailDialog from '@/app/home/knowledge/KBDetailDialog';
|
||||
import KBMigrationDialog from '@/app/home/knowledge/components/kb-migration-dialog/KBMigrationDialog';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
import KBMigrationDialog from '@/app/home/knowledge/components/kb-migration-dialog/KBMigrationDialog';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import KBDetailContent from './KBDetailContent';
|
||||
|
||||
export default function KnowledgePage() {
|
||||
const { t } = useTranslation();
|
||||
const [knowledgeBaseList, setKnowledgeBaseList] = useState<KnowledgeBaseVO[]>(
|
||||
[],
|
||||
);
|
||||
const [selectedKbId, setSelectedKbId] = useState<string>('');
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const searchParams = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
const { refreshKnowledgeBases } = useSidebarData();
|
||||
|
||||
// Migration dialog state
|
||||
// Migration dialog state — checked on page load regardless of detail view
|
||||
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
|
||||
const [migrationInternalCount, setMigrationInternalCount] = useState(0);
|
||||
const [migrationExternalCount, setMigrationExternalCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
getKnowledgeBaseList();
|
||||
checkMigrationStatus();
|
||||
}, []);
|
||||
|
||||
@@ -42,75 +36,27 @@ export default function KnowledgePage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function getKnowledgeBaseList() {
|
||||
const resp = await httpClient.getKnowledgeBases();
|
||||
|
||||
const currentTime = new Date();
|
||||
|
||||
const kbs = resp.bases.map((kb: KnowledgeBase) => {
|
||||
const lastUpdatedTimeAgo = Math.floor(
|
||||
(currentTime.getTime() -
|
||||
new Date(kb.updated_at ?? currentTime.getTime()).getTime()) /
|
||||
1000 /
|
||||
60 /
|
||||
60 /
|
||||
24,
|
||||
);
|
||||
|
||||
const lastUpdatedTimeAgoText =
|
||||
lastUpdatedTimeAgo > 0
|
||||
? ` ${lastUpdatedTimeAgo} ${t('knowledge.daysAgo')}`
|
||||
: t('knowledge.today');
|
||||
|
||||
return new KnowledgeBaseVO({
|
||||
id: kb.uuid || '',
|
||||
name: kb.name,
|
||||
description: kb.description,
|
||||
emoji: kb.emoji,
|
||||
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,
|
||||
ragEngine: kb.knowledge_engine,
|
||||
ragEnginePluginId: kb.knowledge_engine_plugin_id,
|
||||
});
|
||||
});
|
||||
|
||||
setKnowledgeBaseList(kbs);
|
||||
function handleMigrationComplete() {
|
||||
refreshKnowledgeBases();
|
||||
}
|
||||
|
||||
const handleKBCardClick = (kbId: string) => {
|
||||
setSelectedKbId(kbId);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleCreateKBClick = () => {
|
||||
setSelectedKbId('');
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleFormCancel = () => {
|
||||
setDetailDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleKbDeleted = () => {
|
||||
getKnowledgeBaseList();
|
||||
setDetailDialogOpen(false);
|
||||
};
|
||||
|
||||
const handleNewKbCreated = (newKbId: string) => {
|
||||
getKnowledgeBaseList();
|
||||
setSelectedKbId(newKbId);
|
||||
setDetailDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleKbUpdated = () => {
|
||||
getKnowledgeBaseList();
|
||||
};
|
||||
|
||||
const handleMigrationComplete = () => {
|
||||
getKnowledgeBaseList();
|
||||
};
|
||||
if (detailId) {
|
||||
return (
|
||||
<>
|
||||
<KBMigrationDialog
|
||||
open={migrationDialogOpen}
|
||||
onOpenChange={setMigrationDialogOpen}
|
||||
internalKbCount={migrationInternalCount}
|
||||
externalKbCount={migrationExternalCount}
|
||||
onMigrationComplete={handleMigrationComplete}
|
||||
/>
|
||||
<KBDetailContent id={detailId} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<>
|
||||
<KBMigrationDialog
|
||||
open={migrationDialogOpen}
|
||||
onOpenChange={setMigrationDialogOpen}
|
||||
@@ -118,33 +64,9 @@ export default function KnowledgePage() {
|
||||
externalKbCount={migrationExternalCount}
|
||||
onMigrationComplete={handleMigrationComplete}
|
||||
/>
|
||||
|
||||
<KBDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
kbId={selectedKbId || undefined}
|
||||
onFormCancel={handleFormCancel}
|
||||
onKbDeleted={handleKbDeleted}
|
||||
onNewKbCreated={handleNewKbCreated}
|
||||
onKbUpdated={handleKbUpdated}
|
||||
/>
|
||||
|
||||
<div className={styles.knowledgeListContainer}>
|
||||
<CreateCardComponent
|
||||
width={'100%'}
|
||||
height={'10rem'}
|
||||
plusSize={'90px'}
|
||||
onClick={handleCreateKBClick}
|
||||
/>
|
||||
|
||||
{knowledgeBaseList.map((kb) => {
|
||||
return (
|
||||
<div key={kb.id} onClick={() => handleKBCardClick(kb.id)}>
|
||||
<KBCard kbCardVO={kb} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('knowledge.selectFromSidebar')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
/* 主布局容器 */
|
||||
.homeLayoutContainer {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
:global(.dark) .homeLayoutContainer {
|
||||
background-color: #0a0a0b;
|
||||
}
|
||||
|
||||
/* 侧边栏区域 */
|
||||
.sidebar {
|
||||
background-color: #eee;
|
||||
}
|
||||
|
||||
:global(.dark) .sidebar {
|
||||
background-color: #0a0a0b;
|
||||
}
|
||||
|
||||
/* 主内容区域 */
|
||||
.main {
|
||||
background-color: #fafafa;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* height: 100vh; */
|
||||
width: calc(100% - 1.2rem);
|
||||
height: calc(100% - 1.2rem);
|
||||
overflow: hidden;
|
||||
border-radius: 1.5rem 0 0 1.5rem;
|
||||
margin-left: 0.6rem;
|
||||
margin-top: 0.6rem;
|
||||
box-shadow: 0 0 6px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .main {
|
||||
background-color: #151518;
|
||||
box-shadow: 0 0 6px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
padding: 1.5rem;
|
||||
padding-left: 2rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
:global(.dark) .mainContent {
|
||||
background-color: #151518;
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import styles from './layout.module.css';
|
||||
import HomeSidebar from '@/app/home/components/home-sidebar/HomeSidebar';
|
||||
import HomeTitleBar from '@/app/home/components/home-titlebar/HomeTitleBar';
|
||||
import SurveyWidget from '@/app/home/components/survey/SurveyWidget';
|
||||
import React, {
|
||||
useState,
|
||||
@@ -12,20 +10,56 @@ import React, {
|
||||
Suspense,
|
||||
} from 'react';
|
||||
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
|
||||
import {
|
||||
SidebarDataProvider,
|
||||
useSidebarData,
|
||||
} from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
import { userInfo, initializeUserInfo } from '@/app/infra/http';
|
||||
import {
|
||||
userInfo,
|
||||
systemInfo,
|
||||
initializeUserInfo,
|
||||
initializeSystemInfo,
|
||||
} from '@/app/infra/http';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
SidebarInset,
|
||||
SidebarProvider,
|
||||
SidebarTrigger,
|
||||
} from '@/components/ui/sidebar';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from '@/components/ui/breadcrumb';
|
||||
import {
|
||||
PluginInstallTaskProvider,
|
||||
PluginInstallProgressDialog,
|
||||
} from '@/app/home/plugins/components/plugin-install-task';
|
||||
|
||||
// Routes that belong to the "Extensions" section
|
||||
const EXTENSIONS_ROUTES = ['/home/plugins', '/home/market', '/home/mcp'];
|
||||
|
||||
function isExtensionsRoute(pathname: string): boolean {
|
||||
return EXTENSIONS_ROUTES.some(
|
||||
(route) => pathname === route || pathname.startsWith(route + '/'),
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [subtitle, setSubtitle] = useState<string>('');
|
||||
const [helpLink, setHelpLink] = useState<I18nObject>({
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
});
|
||||
const router = useRouter();
|
||||
|
||||
// Initialize user info if not already initialized
|
||||
useEffect(() => {
|
||||
@@ -34,30 +68,117 @@ export default function HomeLayout({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-redirect to wizard on first visit (wizard not yet completed on this instance)
|
||||
useEffect(() => {
|
||||
const checkWizard = async () => {
|
||||
try {
|
||||
// Always re-fetch to ensure we have the latest wizard_status from backend
|
||||
await initializeSystemInfo();
|
||||
if (systemInfo.wizard_status === 'none') {
|
||||
router.replace('/wizard');
|
||||
}
|
||||
} catch {
|
||||
// If fetching system info fails, don't redirect
|
||||
}
|
||||
};
|
||||
checkWizard();
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<SidebarDataProvider>
|
||||
<PluginInstallTaskProvider>
|
||||
<HomeLayoutInner>{children}</HomeLayoutInner>
|
||||
<PluginInstallProgressDialog />
|
||||
</PluginInstallTaskProvider>
|
||||
</SidebarDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function HomeLayoutInner({ children }: { children: React.ReactNode }) {
|
||||
const [title, setTitle] = useState<string>('');
|
||||
const [helpLink, setHelpLink] = useState<I18nObject>({
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
});
|
||||
const { detailEntityName } = useSidebarData();
|
||||
const pathname = usePathname();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
|
||||
setTitle(child.name);
|
||||
setSubtitle(child.description);
|
||||
setHelpLink(child.helpLink);
|
||||
}, []);
|
||||
|
||||
// Memoize the main content area to prevent re-renders when sidebar state changes
|
||||
const mainContent = useMemo(() => children, [children]);
|
||||
|
||||
const resolvedHelpLink = extractI18nObject(helpLink);
|
||||
|
||||
// Determine breadcrumb section label and default link based on current route
|
||||
const isExtensions = isExtensionsRoute(pathname);
|
||||
const sectionLabel = isExtensions
|
||||
? t('sidebar.extensions')
|
||||
: t('sidebar.home');
|
||||
const sectionLink = isExtensions ? '/home/plugins' : '/home/monitoring';
|
||||
|
||||
return (
|
||||
<div className={styles.homeLayoutContainer}>
|
||||
<aside className={styles.sidebar}>
|
||||
<Suspense fallback={<div />}>
|
||||
<HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />
|
||||
</Suspense>
|
||||
</aside>
|
||||
<SidebarProvider>
|
||||
<Suspense fallback={<div />}>
|
||||
<HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />
|
||||
</Suspense>
|
||||
|
||||
<div className={styles.main}>
|
||||
<HomeTitleBar title={title} subtitle={subtitle} helpLink={helpLink} />
|
||||
<SidebarInset>
|
||||
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex items-center gap-2 px-4">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem className="hidden md:block">
|
||||
<BreadcrumbLink asChild>
|
||||
<Link href={sectionLink}>{sectionLabel}</Link>
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator className="hidden md:block" />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{title}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
{detailEntityName && (
|
||||
<>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>{detailEntityName}</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
{resolvedHelpLink && (
|
||||
<>
|
||||
<BreadcrumbItem>
|
||||
<a
|
||||
href={resolvedHelpLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<CircleHelp className="size-3.5" />
|
||||
</a>
|
||||
</BreadcrumbItem>
|
||||
</>
|
||||
)}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className={styles.mainContent}>{mainContent}</main>
|
||||
<div className="flex-1 overflow-hidden p-4 pt-0 min-w-0">
|
||||
{mainContent}
|
||||
</div>
|
||||
|
||||
<SurveyWidget />
|
||||
</div>
|
||||
</div>
|
||||
</SidebarInset>
|
||||
</SidebarProvider>
|
||||
);
|
||||
}
|
||||
|
||||
202
web/src/app/home/market/page.tsx
Normal file
202
web/src/app/home/market/page.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
|
||||
import MarketPage from '@/app/home/plugins/components/plugin-market/PluginMarketComponent';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Download } from 'lucide-react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
|
||||
|
||||
enum PluginInstallStatus {
|
||||
ASK_CONFIRM = 'ask_confirm',
|
||||
INSTALLING = 'installing',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export default function MarketplacePage() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!systemInfo?.enable_marketplace) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
|
||||
<p className="text-muted-foreground">{t('plugins.marketplace')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <MarketplaceContent />;
|
||||
}
|
||||
|
||||
function MarketplaceContent() {
|
||||
const { t } = useTranslation();
|
||||
const { refreshPlugins } = useSidebarData();
|
||||
const {
|
||||
addTask,
|
||||
setSelectedTaskId,
|
||||
registerOnTaskComplete,
|
||||
unregisterOnTaskComplete,
|
||||
} = usePluginInstallTasks();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [installInfo, setInstallInfo] = useState<Record<string, string>>({});
|
||||
const [pluginInstallStatus, setPluginInstallStatus] =
|
||||
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
|
||||
async function checkExtensionsLimit(): Promise<boolean> {
|
||||
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
|
||||
if (maxExtensions < 0) return true;
|
||||
try {
|
||||
const [pluginsResp, mcpResp] = await Promise.all([
|
||||
httpClient.getPlugins(),
|
||||
httpClient.getMCPServers(),
|
||||
]);
|
||||
const total =
|
||||
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
|
||||
if (total >= maxExtensions) {
|
||||
toast.error(
|
||||
t('limitation.maxExtensionsReached', { max: maxExtensions }),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, let backend handle it
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Register task completion callback for toast and plugin list refresh
|
||||
useEffect(() => {
|
||||
const onComplete = (_taskId: number, success: boolean) => {
|
||||
if (success) {
|
||||
toast.success(t('plugins.installSuccess'));
|
||||
refreshPlugins();
|
||||
}
|
||||
};
|
||||
registerOnTaskComplete(onComplete);
|
||||
return () => {
|
||||
unregisterOnTaskComplete(onComplete);
|
||||
};
|
||||
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
|
||||
|
||||
const handleInstallPlugin = useCallback(
|
||||
async (plugin: PluginV4) => {
|
||||
if (!(await checkExtensionsLimit())) return;
|
||||
setInstallInfo({
|
||||
plugin_author: plugin.author,
|
||||
plugin_name: plugin.name,
|
||||
plugin_version: plugin.latest_version,
|
||||
});
|
||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||
setInstallError(null);
|
||||
setModalOpen(true);
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
function handleModalConfirm() {
|
||||
setPluginInstallStatus(PluginInstallStatus.INSTALLING);
|
||||
const pluginDisplayName = `${installInfo.plugin_author}/${installInfo.plugin_name}`;
|
||||
httpClient
|
||||
.installPluginFromMarketplace(
|
||||
installInfo.plugin_author,
|
||||
installInfo.plugin_name,
|
||||
installInfo.plugin_version,
|
||||
)
|
||||
.then((resp) => {
|
||||
const taskId = resp.task_id;
|
||||
const taskKey = `marketplace-${taskId}`;
|
||||
addTask({
|
||||
taskId,
|
||||
pluginName: pluginDisplayName,
|
||||
source: 'marketplace',
|
||||
});
|
||||
setSelectedTaskId(taskKey);
|
||||
setModalOpen(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setInstallError(err.msg);
|
||||
setPluginInstallStatus(PluginInstallStatus.ERROR);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="h-full overflow-y-auto">
|
||||
<MarketPage installPlugin={handleInstallPlugin} />
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
open={modalOpen}
|
||||
onOpenChange={(open) => {
|
||||
setModalOpen(open);
|
||||
if (!open) {
|
||||
setInstallError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="w-[500px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-4">
|
||||
<Download className="size-6" />
|
||||
<span>{t('plugins.installPlugin')}</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">
|
||||
{t('plugins.askConfirm', {
|
||||
name: installInfo.plugin_name,
|
||||
version: installInfo.plugin_version,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pluginInstallStatus === PluginInstallStatus.INSTALLING && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.installing')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">{t('plugins.installFailed')}</p>
|
||||
<p className="mb-2 text-red-500">{installError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleModalConfirm}>
|
||||
{t('common.confirm')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{pluginInstallStatus === PluginInstallStatus.ERROR && (
|
||||
<Button variant="default" onClick={() => setModalOpen(false)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
301
web/src/app/home/mcp/MCPDetailContent.tsx
Normal file
301
web/src/app/home/mcp/MCPDetailContent.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import MCPForm from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||
import type { MCPFormHandle } from '@/app/home/mcp/components/mcp-form/MCPForm';
|
||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function MCPDetailContent({ id }: { id: string }) {
|
||||
const isCreateMode = id === 'new';
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const { refreshMCPServers, mcpServers, setDetailEntityName } =
|
||||
useSidebarData();
|
||||
|
||||
// Set breadcrumb entity name
|
||||
useEffect(() => {
|
||||
if (isCreateMode) {
|
||||
setDetailEntityName(t('mcp.createServer'));
|
||||
} else {
|
||||
const server = mcpServers.find((s) => s.id === id);
|
||||
setDetailEntityName(server?.name ?? id);
|
||||
}
|
||||
return () => setDetailEntityName(null);
|
||||
}, [id, isCreateMode, mcpServers, setDetailEntityName, t]);
|
||||
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
// Track whether the form has unsaved changes
|
||||
const [formDirty, setFormDirty] = useState(false);
|
||||
|
||||
// Ref to MCPForm for triggering test from header
|
||||
const formRef = useRef<MCPFormHandle>(null);
|
||||
const [mcpTesting, setMcpTesting] = useState(false);
|
||||
|
||||
// Enable state managed here so the header switch works
|
||||
const [serverEnabled, setServerEnabled] = useState(true);
|
||||
const [enableLoaded, setEnableLoaded] = useState(false);
|
||||
|
||||
// Fetch server enable state
|
||||
useEffect(() => {
|
||||
if (!isCreateMode) {
|
||||
httpClient.getMCPServer(id).then((res) => {
|
||||
const server = res.server ?? res;
|
||||
setServerEnabled(server.enable ?? true);
|
||||
setEnableLoaded(true);
|
||||
});
|
||||
}
|
||||
}, [id, isCreateMode]);
|
||||
|
||||
const handleEnableToggle = useCallback(
|
||||
async (checked: boolean) => {
|
||||
const prev = serverEnabled;
|
||||
setServerEnabled(checked);
|
||||
try {
|
||||
await httpClient.toggleMCPServer(id, checked);
|
||||
refreshMCPServers();
|
||||
} catch {
|
||||
setServerEnabled(prev);
|
||||
toast.error(t('mcp.modifyFailed'));
|
||||
}
|
||||
},
|
||||
[id, serverEnabled, refreshMCPServers, t],
|
||||
);
|
||||
|
||||
function handleFormSubmit() {
|
||||
// Re-sync enable state after form save
|
||||
httpClient.getMCPServer(id).then((res) => {
|
||||
const server = res.server ?? res;
|
||||
setServerEnabled(server.enable ?? true);
|
||||
});
|
||||
refreshMCPServers();
|
||||
}
|
||||
|
||||
function handleServerDeleted() {
|
||||
refreshMCPServers();
|
||||
router.push('/home/mcp');
|
||||
}
|
||||
|
||||
function handleNewServerCreated(serverName: string) {
|
||||
refreshMCPServers();
|
||||
router.push(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
||||
}
|
||||
|
||||
function confirmDelete() {
|
||||
httpClient
|
||||
.deleteMCPServer(id)
|
||||
.then(() => {
|
||||
setShowDeleteConfirm(false);
|
||||
toast.success(t('mcp.deleteSuccess'));
|
||||
handleServerDeleted();
|
||||
})
|
||||
.catch((err) => {
|
||||
toast.error(t('mcp.deleteFailed') + (err.msg || ''));
|
||||
});
|
||||
}
|
||||
|
||||
// Check extensions limit before creating
|
||||
async function checkExtensionsLimit(): Promise<boolean> {
|
||||
const maxExtensions = systemInfo.limitation?.max_extensions ?? -1;
|
||||
if (maxExtensions < 0) return true;
|
||||
try {
|
||||
const [pluginsResp, mcpResp] = await Promise.all([
|
||||
httpClient.getPlugins(),
|
||||
httpClient.getMCPServers(),
|
||||
]);
|
||||
const total =
|
||||
(pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0);
|
||||
if (total >= maxExtensions) {
|
||||
toast.error(
|
||||
t('limitation.maxExtensionsReached', { max: maxExtensions }),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
} catch {
|
||||
// If we can't check, let backend handle it
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== Create Mode ====================
|
||||
if (isCreateMode) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('mcp.createServer')}</h1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => formRef.current?.testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="mcp-form"
|
||||
onClick={async (e) => {
|
||||
if (!(await checkExtensionsLimit())) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('common.submit')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl pb-8">
|
||||
<MCPForm
|
||||
ref={formRef}
|
||||
initServerName={undefined}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onTestingChange={setMcpTesting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== Edit Mode ====================
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header: title + enable switch + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-semibold">{t('mcp.editServer')}</h1>
|
||||
{enableLoaded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="mcp-enable-switch"
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={handleEnableToggle}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="mcp-enable-switch"
|
||||
className="text-sm text-muted-foreground cursor-pointer"
|
||||
>
|
||||
{t('common.enable')}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => formRef.current?.testMcp()}
|
||||
disabled={mcpTesting}
|
||||
>
|
||||
{t('common.test')}
|
||||
</Button>
|
||||
<Button type="submit" form="mcp-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="mx-auto max-w-3xl space-y-6 pb-8">
|
||||
<MCPForm
|
||||
ref={formRef}
|
||||
initServerName={id}
|
||||
onFormSubmit={handleFormSubmit}
|
||||
onNewServerCreated={handleNewServerCreated}
|
||||
onDirtyChange={setFormDirty}
|
||||
onTestingChange={setMcpTesting}
|
||||
/>
|
||||
|
||||
{/* Card: Danger Zone */}
|
||||
<Card className="border-destructive/50">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">
|
||||
{t('mcp.dangerZone')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('mcp.dangerZoneDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
{t('mcp.deleteMCPAction')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('mcp.deleteMCPHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
>
|
||||
<Trash2 className="size-4 mr-1.5" />
|
||||
{t('common.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('mcp.confirmDeleteTitle')}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{t('mcp.confirmDeleteServer')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{t('mcp.confirmDeleteServer')}</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t('common.confirmDelete')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
926
web/src/app/home/mcp/components/mcp-form/MCPForm.tsx
Normal file
926
web/src/app/home/mcp/components/mcp-form/MCPForm.tsx
Normal file
@@ -0,0 +1,926 @@
|
||||
'use client';
|
||||
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Resolver, useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import {
|
||||
MCPServerRuntimeInfo,
|
||||
MCPTool,
|
||||
MCPServer,
|
||||
MCPSessionStatus,
|
||||
MCPServerExtraArgsSSE,
|
||||
MCPServerExtraArgsHttp,
|
||||
MCPServerExtraArgsStdio,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
|
||||
// Status display for test / connecting / error states
|
||||
function StatusDisplay({
|
||||
testing,
|
||||
runtimeInfo,
|
||||
t,
|
||||
}: {
|
||||
testing: boolean;
|
||||
runtimeInfo: MCPServerRuntimeInfo;
|
||||
t: (key: string) => string;
|
||||
}) {
|
||||
if (testing) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<svg
|
||||
className="w-5 h-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{t('mcp.testing')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<svg
|
||||
className="w-5 h-5 animate-spin"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{t('mcp.connecting')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{t('mcp.connectionFailed')}</span>
|
||||
</div>
|
||||
{runtimeInfo.error_message && (
|
||||
<div className="text-sm text-red-500 pl-7">
|
||||
{runtimeInfo.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tools list component
|
||||
function ToolsList({ tools }: { tools: MCPTool[] }) {
|
||||
return (
|
||||
<div className="space-y-2 max-h-[300px] overflow-y-auto">
|
||||
{tools.map((tool, index) => (
|
||||
<Card key={index} className="py-3 shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{tool.name}</CardTitle>
|
||||
{tool.description && (
|
||||
<CardDescription className="text-xs">
|
||||
{tool.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const getFormSchema = (t: (key: string) => string) =>
|
||||
z
|
||||
.object({
|
||||
name: z
|
||||
.string({ required_error: t('mcp.nameRequired') })
|
||||
.min(1, { message: t('mcp.nameRequired') }),
|
||||
mode: z.enum(['sse', 'stdio', 'http']),
|
||||
timeout: z
|
||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
.default(30),
|
||||
ssereadtimeout: z
|
||||
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
.default(300),
|
||||
url: z.string().optional(),
|
||||
command: z.string().optional(),
|
||||
args: z.array(z.object({ value: z.string() })).optional(),
|
||||
extra_args: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string(),
|
||||
type: z.enum(['string', 'number', 'boolean']),
|
||||
value: z.string(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.mode === 'sse' || data.mode === 'http') {
|
||||
if (!data.url || data.url.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('mcp.urlRequired'),
|
||||
path: ['url'],
|
||||
});
|
||||
}
|
||||
} else if (data.mode === 'stdio') {
|
||||
if (!data.command || data.command.length === 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t('mcp.commandRequired'),
|
||||
path: ['command'],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
|
||||
timeout: number;
|
||||
ssereadtimeout: number;
|
||||
};
|
||||
|
||||
interface MCPFormProps {
|
||||
initServerName?: string;
|
||||
onFormSubmit: () => void;
|
||||
onNewServerCreated: (serverName: string) => void;
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
onTestingChange?: (testing: boolean) => void;
|
||||
}
|
||||
|
||||
// Handle exposed to parent via ref
|
||||
export interface MCPFormHandle {
|
||||
testMcp: () => void;
|
||||
isTesting: boolean;
|
||||
}
|
||||
|
||||
const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
{
|
||||
initServerName,
|
||||
onFormSubmit,
|
||||
onNewServerCreated,
|
||||
onDirtyChange,
|
||||
onTestingChange,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const { t } = useTranslation();
|
||||
const formSchema = getFormSchema(t);
|
||||
const isEditMode = !!initServerName;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema) as unknown as Resolver<FormValues>,
|
||||
defaultValues: {
|
||||
name: '',
|
||||
mode: 'sse',
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
},
|
||||
});
|
||||
|
||||
// Track whether initial data loading is complete (to avoid marking form dirty)
|
||||
const isInitializing = useRef(true);
|
||||
|
||||
const [extraArgs, setExtraArgs] = useState<
|
||||
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
|
||||
>([]);
|
||||
const [stdioArgs, setStdioArgs] = useState<{ value: string }[]>([]);
|
||||
const [mcpTesting, setMcpTesting] = useState(false);
|
||||
const [runtimeInfo, setRuntimeInfo] = useState<MCPServerRuntimeInfo | null>(
|
||||
null,
|
||||
);
|
||||
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const watchMode = form.watch('mode');
|
||||
|
||||
// Notify parent when dirty state changes
|
||||
const { isDirty } = form.formState;
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(isDirty);
|
||||
}, [isDirty, onDirtyChange]);
|
||||
|
||||
// Notify parent when testing state changes
|
||||
useEffect(() => {
|
||||
onTestingChange?.(mcpTesting);
|
||||
}, [mcpTesting, onTestingChange]);
|
||||
|
||||
// Expose test action and testing state to parent
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
testMcp: () => testMcp(),
|
||||
isTesting: mcpTesting,
|
||||
}),
|
||||
[mcpTesting],
|
||||
);
|
||||
|
||||
// Load server data
|
||||
useEffect(() => {
|
||||
isInitializing.current = true;
|
||||
if (isEditMode && initServerName) {
|
||||
loadServerForEdit(initServerName).finally(() => {
|
||||
isInitializing.current = false;
|
||||
});
|
||||
} else {
|
||||
form.reset({
|
||||
name: '',
|
||||
mode: 'sse',
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
});
|
||||
setExtraArgs([]);
|
||||
setStdioArgs([]);
|
||||
setRuntimeInfo(null);
|
||||
isInitializing.current = false;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [initServerName]);
|
||||
|
||||
// Poll for updates when runtime_info status is CONNECTING
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isEditMode ||
|
||||
!initServerName ||
|
||||
!runtimeInfo ||
|
||||
runtimeInfo.status !== MCPSessionStatus.CONNECTING
|
||||
) {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pollingIntervalRef.current) {
|
||||
pollingIntervalRef.current = setInterval(() => {
|
||||
loadServerForEdit(initServerName);
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollingIntervalRef.current) {
|
||||
clearInterval(pollingIntervalRef.current);
|
||||
pollingIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [isEditMode, initServerName, runtimeInfo?.status]);
|
||||
|
||||
async function loadServerForEdit(serverName: string) {
|
||||
try {
|
||||
const resp = await httpClient.getMCPServer(serverName);
|
||||
const server = resp.server ?? resp;
|
||||
|
||||
const formValues: FormValues = {
|
||||
name: server.name,
|
||||
mode: server.mode,
|
||||
url: '',
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
};
|
||||
|
||||
let newExtraArgs: {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
value: string;
|
||||
}[] = [];
|
||||
let newStdioArgs: { value: string }[] = [];
|
||||
|
||||
if (server.mode === 'sse' || server.mode === 'http') {
|
||||
formValues.url = server.extra_args.url;
|
||||
formValues.timeout = server.extra_args.timeout;
|
||||
|
||||
if (server.mode === 'sse') {
|
||||
formValues.ssereadtimeout = server.extra_args.ssereadtimeout;
|
||||
}
|
||||
|
||||
if (server.extra_args.headers) {
|
||||
newExtraArgs = Object.entries(server.extra_args.headers).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
type: 'string' as const,
|
||||
value: String(value),
|
||||
}),
|
||||
);
|
||||
formValues.extra_args = newExtraArgs;
|
||||
}
|
||||
} else if (server.mode === 'stdio') {
|
||||
formValues.command = server.extra_args.command;
|
||||
newStdioArgs = (server.extra_args.args || []).map((arg: string) => ({
|
||||
value: arg,
|
||||
}));
|
||||
formValues.args = newStdioArgs;
|
||||
|
||||
if (server.extra_args.env) {
|
||||
newExtraArgs = Object.entries(server.extra_args.env).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
type: 'string' as const,
|
||||
value: String(value),
|
||||
}),
|
||||
);
|
||||
formValues.extra_args = newExtraArgs;
|
||||
}
|
||||
}
|
||||
|
||||
setExtraArgs(newExtraArgs);
|
||||
setStdioArgs(newStdioArgs);
|
||||
|
||||
// Use form.reset so isDirty stays false after initial load
|
||||
form.reset(formValues);
|
||||
|
||||
if (server.runtime_info) {
|
||||
setRuntimeInfo(server.runtime_info);
|
||||
} else {
|
||||
setRuntimeInfo(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load server:', error);
|
||||
toast.error(t('mcp.loadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFormSubmit(value: z.infer<typeof formSchema>) {
|
||||
try {
|
||||
let serverConfig: MCPServer;
|
||||
|
||||
if (value.mode === 'sse' || value.mode === 'http') {
|
||||
const headers: Record<string, string> = {};
|
||||
value.extra_args?.forEach((arg) => {
|
||||
headers[arg.key] = String(arg.value);
|
||||
});
|
||||
|
||||
if (value.mode === 'sse') {
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
mode: 'sse',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: value.url!,
|
||||
headers: headers,
|
||||
timeout: value.timeout,
|
||||
ssereadtimeout: value.ssereadtimeout,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
mode: 'http',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
url: value.url!,
|
||||
headers: headers,
|
||||
timeout: value.timeout,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const env: Record<string, string> = {};
|
||||
value.extra_args?.forEach((arg) => {
|
||||
env[arg.key] = String(arg.value);
|
||||
});
|
||||
const args = value.args?.map((arg) => arg.value) || [];
|
||||
|
||||
serverConfig = {
|
||||
name: value.name,
|
||||
mode: 'stdio',
|
||||
enable: true,
|
||||
extra_args: {
|
||||
command: value.command!,
|
||||
args: args,
|
||||
env: env,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (isEditMode && initServerName) {
|
||||
await httpClient.updateMCPServer(initServerName, serverConfig);
|
||||
toast.success(t('mcp.updateSuccess'));
|
||||
// Reset dirty baseline to current values
|
||||
form.reset(form.getValues());
|
||||
onFormSubmit();
|
||||
} else {
|
||||
await httpClient.createMCPServer(serverConfig);
|
||||
toast.success(t('mcp.createSuccess'));
|
||||
onNewServerCreated(value.name);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save MCP server:', error);
|
||||
const errMsg = (error as CustomApiError).msg || '';
|
||||
toast.error(
|
||||
(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')) + errMsg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function testMcp() {
|
||||
setMcpTesting(true);
|
||||
|
||||
try {
|
||||
const mode = form.getValues('mode');
|
||||
let extraArgsData:
|
||||
| MCPServerExtraArgsSSE
|
||||
| MCPServerExtraArgsHttp
|
||||
| MCPServerExtraArgsStdio;
|
||||
|
||||
if (mode === 'sse') {
|
||||
extraArgsData = {
|
||||
url: form.getValues('url')!,
|
||||
timeout: form.getValues('timeout'),
|
||||
headers: Object.fromEntries(
|
||||
extraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
ssereadtimeout: form.getValues('ssereadtimeout'),
|
||||
};
|
||||
} else if (mode === 'http') {
|
||||
extraArgsData = {
|
||||
url: form.getValues('url')!,
|
||||
timeout: form.getValues('timeout'),
|
||||
headers: Object.fromEntries(
|
||||
extraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
};
|
||||
} else {
|
||||
extraArgsData = {
|
||||
command: form.getValues('command')!,
|
||||
args: stdioArgs.map((arg) => arg.value),
|
||||
env: Object.fromEntries(extraArgs.map((arg) => [arg.key, arg.value])),
|
||||
};
|
||||
}
|
||||
|
||||
const { task_id } = await httpClient.testMCPServer('_', {
|
||||
name: form.getValues('name'),
|
||||
mode: mode,
|
||||
enable: true,
|
||||
extra_args: extraArgsData,
|
||||
} as MCPServer);
|
||||
|
||||
if (!task_id) {
|
||||
throw new Error(t('mcp.noTaskId'));
|
||||
}
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const taskResp = await httpClient.getAsyncTask(task_id);
|
||||
|
||||
if (taskResp.runtime?.done) {
|
||||
clearInterval(interval);
|
||||
setMcpTesting(false);
|
||||
|
||||
if (taskResp.runtime.exception) {
|
||||
const errorMsg =
|
||||
taskResp.runtime.exception || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
setRuntimeInfo({
|
||||
status: MCPSessionStatus.ERROR,
|
||||
error_message: errorMsg,
|
||||
tool_count: 0,
|
||||
tools: [],
|
||||
});
|
||||
} else {
|
||||
if (isEditMode) {
|
||||
await loadServerForEdit(form.getValues('name'));
|
||||
}
|
||||
toast.success(t('mcp.testSuccess'));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
clearInterval(interval);
|
||||
setMcpTesting(false);
|
||||
const errorMsg =
|
||||
(err as CustomApiError).msg || t('mcp.getTaskFailed');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
}
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
setMcpTesting(false);
|
||||
const errorMsg = (err as Error).message || t('mcp.unknownError');
|
||||
toast.error(`${t('mcp.testError')}: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
const addExtraArg = () => {
|
||||
const newArgs = [
|
||||
...extraArgs,
|
||||
{ key: '', type: 'string' as const, value: '' },
|
||||
];
|
||||
setExtraArgs(newArgs);
|
||||
form.setValue('extra_args', newArgs, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
};
|
||||
|
||||
const removeExtraArg = (index: number) => {
|
||||
const newArgs = extraArgs.filter((_, i) => i !== index);
|
||||
setExtraArgs(newArgs);
|
||||
form.setValue('extra_args', newArgs, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
};
|
||||
|
||||
const updateExtraArg = (
|
||||
index: number,
|
||||
field: 'key' | 'type' | 'value',
|
||||
value: string,
|
||||
) => {
|
||||
const newArgs = [...extraArgs];
|
||||
newArgs[index] = { ...newArgs[index], [field]: value };
|
||||
setExtraArgs(newArgs);
|
||||
form.setValue('extra_args', newArgs, {
|
||||
shouldDirty: !isInitializing.current,
|
||||
});
|
||||
};
|
||||
|
||||
const addStdioArg = () => {
|
||||
const newArgs = [...stdioArgs, { value: '' }];
|
||||
setStdioArgs(newArgs);
|
||||
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
|
||||
};
|
||||
|
||||
const removeStdioArg = (index: number) => {
|
||||
const newArgs = stdioArgs.filter((_, i) => i !== index);
|
||||
setStdioArgs(newArgs);
|
||||
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
|
||||
};
|
||||
|
||||
const updateStdioArg = (index: number, value: string) => {
|
||||
const newArgs = [...stdioArgs];
|
||||
newArgs[index] = { value };
|
||||
setStdioArgs(newArgs);
|
||||
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="mcp-form"
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Runtime info: status + tools (edit mode only) */}
|
||||
{isEditMode && runtimeInfo && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{t('mcp.title')}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{(mcpTesting ||
|
||||
runtimeInfo.status !== MCPSessionStatus.CONNECTED) && (
|
||||
<div className="p-3 rounded-lg border">
|
||||
<StatusDisplay
|
||||
testing={mcpTesting}
|
||||
runtimeInfo={runtimeInfo}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!mcpTesting &&
|
||||
runtimeInfo.status === MCPSessionStatus.CONNECTED &&
|
||||
runtimeInfo.tools?.length > 0 && (
|
||||
<>
|
||||
<div className="text-sm font-medium">
|
||||
{t('mcp.toolCount', {
|
||||
count: runtimeInfo.tools?.length || 0,
|
||||
})}
|
||||
</div>
|
||||
<ToolsList tools={runtimeInfo.tools} />
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Server configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{isEditMode ? t('mcp.editServer') : t('mcp.createServer')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t('mcp.extraParametersDescription')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('mcp.name')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="mode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.serverMode')}</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('mcp.selectMode')} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">{t('mcp.http')}</SelectItem>
|
||||
<SelectItem value="stdio">{t('mcp.stdio')}</SelectItem>
|
||||
<SelectItem value="sse">{t('mcp.sse')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{(watchMode === 'sse' || watchMode === 'http') && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="url"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('mcp.url')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="timeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.timeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('mcp.timeout')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchMode === 'sse' && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="ssereadtimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.sseTimeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder={t('mcp.sseTimeoutDescription')}
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(Number(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{watchMode === 'stdio' && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="command"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t('mcp.command')}
|
||||
<span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.args')}</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{stdioArgs.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('mcp.args')}
|
||||
value={arg.value}
|
||||
onChange={(e) =>
|
||||
updateStdioArg(index, e.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-red-500 hover:text-red-600"
|
||||
onClick={() => removeStdioArg(index)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addStdioArg}
|
||||
>
|
||||
{t('mcp.addArgument')}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
</>
|
||||
)}
|
||||
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{watchMode === 'sse' || watchMode === 'http'
|
||||
? t('mcp.headers')
|
||||
: t('mcp.env')}
|
||||
</FormLabel>
|
||||
<div className="space-y-2">
|
||||
{extraArgs.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('models.keyName')}
|
||||
value={arg.key}
|
||||
onChange={(e) =>
|
||||
updateExtraArg(index, 'key', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('models.value')}
|
||||
value={arg.value}
|
||||
onChange={(e) =>
|
||||
updateExtraArg(index, 'value', e.target.value)
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="shrink-0 text-red-500 hover:text-red-600"
|
||||
onClick={() => removeExtraArg(index)}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
className="w-5 h-5"
|
||||
>
|
||||
<path d="M7 4V2H17V4H22V6H20V21C20 21.5523 19.5523 22 19 22H5C4.44772 22 4 21.5523 4 21V6H2V4H7ZM6 6V20H18V6H6ZM9 9H11V17H9V9ZM13 9H15V17H13V9Z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button type="button" variant="outline" onClick={addExtraArg}>
|
||||
{watchMode === 'sse' || watchMode === 'http'
|
||||
? t('mcp.addHeader')
|
||||
: t('mcp.addEnvVar')}
|
||||
</Button>
|
||||
</div>
|
||||
<FormDescription>
|
||||
{t('mcp.extraParametersDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
});
|
||||
|
||||
export default MCPForm;
|
||||
21
web/src/app/home/mcp/page.tsx
Normal file
21
web/src/app/home/mcp/page.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import MCPDetailContent from './MCPDetailContent';
|
||||
|
||||
export default function MCPPage() {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
const detailId = searchParams.get('id');
|
||||
|
||||
if (detailId) {
|
||||
return <MCPDetailContent id={detailId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
<p>{t('mcp.selectFromSidebar')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -172,7 +172,7 @@ export function ExportDropdown({ filterState }: ExportDropdownProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600 shadow-sm flex-shrink-0"
|
||||
className="shadow-sm flex-shrink-0"
|
||||
disabled={exporting !== null}
|
||||
>
|
||||
{exporting ? (
|
||||
|
||||
187
web/src/app/home/monitoring/components/FeedbackCard.tsx
Normal file
187
web/src/app/home/monitoring/components/FeedbackCard.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface FeedbackCardProps {
|
||||
title: string;
|
||||
value: number | string;
|
||||
subtitle?: string;
|
||||
icon: React.ReactNode;
|
||||
trend?: {
|
||||
value: number;
|
||||
direction: 'up' | 'down' | 'neutral';
|
||||
};
|
||||
variant?: 'default' | 'success' | 'warning' | 'danger';
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function FeedbackCard({
|
||||
title,
|
||||
value,
|
||||
subtitle,
|
||||
icon,
|
||||
trend,
|
||||
variant = 'default',
|
||||
loading = false,
|
||||
}: FeedbackCardProps) {
|
||||
const variantStyles = {
|
||||
default: 'bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700',
|
||||
success:
|
||||
'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800',
|
||||
warning:
|
||||
'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800',
|
||||
danger: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800',
|
||||
};
|
||||
|
||||
const iconStyles = {
|
||||
default: 'text-gray-500 dark:text-gray-400',
|
||||
success: 'text-green-500 dark:text-green-400',
|
||||
warning: 'text-yellow-500 dark:text-yellow-400',
|
||||
danger: 'text-red-500 dark:text-red-400',
|
||||
};
|
||||
|
||||
const trendStyles = {
|
||||
up: 'text-green-500',
|
||||
down: 'text-red-500',
|
||||
neutral: 'text-gray-500',
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div
|
||||
className={`p-6 rounded-xl border shadow-sm ${variantStyles.default} animate-pulse`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-20 mb-2" />
|
||||
<div className="h-8 bg-gray-200 dark:bg-gray-700 rounded w-16 mb-1" />
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-24" />
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-gray-200 dark:bg-gray-700 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`p-6 rounded-xl border shadow-sm ${variantStyles[variant]}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400 mb-1">
|
||||
{title}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{value}
|
||||
</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
{trend && (
|
||||
<div
|
||||
className={`flex items-center mt-2 text-sm ${trendStyles[trend.direction]}`}
|
||||
>
|
||||
{trend.direction === 'up' && (
|
||||
<TrendingUp className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{trend.direction === 'down' && (
|
||||
<TrendingDown className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
{trend.direction === 'neutral' && (
|
||||
<Minus className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
<span>
|
||||
{trend.value > 0 ? '+' : ''}
|
||||
{trend.value}%
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`p-3 rounded-lg bg-gray-100 dark:bg-gray-800 ${iconStyles[variant]}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface FeedbackStatsProps {
|
||||
stats: {
|
||||
totalFeedback: number;
|
||||
totalLikes: number;
|
||||
totalDislikes: number;
|
||||
satisfactionRate: number;
|
||||
} | null;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export function FeedbackStatsCards({ stats, loading }: FeedbackStatsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: t('monitoring.feedback.totalFeedback'),
|
||||
value: stats?.totalFeedback ?? 0,
|
||||
icon: (
|
||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
),
|
||||
variant: 'default' as const,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.feedback.totalLikes'),
|
||||
value: stats?.totalLikes ?? 0,
|
||||
icon: <ThumbsUp className="w-6 h-6" />,
|
||||
variant: 'success' as const,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.feedback.totalDislikes'),
|
||||
value: stats?.totalDislikes ?? 0,
|
||||
icon: <ThumbsDown className="w-6 h-6" />,
|
||||
variant: 'danger' as const,
|
||||
},
|
||||
{
|
||||
title: t('monitoring.feedback.satisfactionRate'),
|
||||
value: stats ? `${stats.satisfactionRate}%` : '0%',
|
||||
icon: (
|
||||
<svg className="w-6 h-6" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5 7.67 11 8.5 11zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
||||
</svg>
|
||||
),
|
||||
variant: (stats && stats.satisfactionRate >= 80
|
||||
? 'success'
|
||||
: stats && stats.satisfactionRate >= 50
|
||||
? 'warning'
|
||||
: 'danger') as 'default' | 'success' | 'warning' | 'danger',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
{cards.map((card, index) => (
|
||||
<FeedbackCard
|
||||
key={index}
|
||||
title={card.title}
|
||||
value={card.value}
|
||||
icon={card.icon}
|
||||
variant={card.variant}
|
||||
loading={loading}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
web/src/app/home/monitoring/components/FeedbackList.tsx
Normal file
275
web/src/app/home/monitoring/components/FeedbackList.tsx
Normal file
@@ -0,0 +1,275 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
} from 'lucide-react';
|
||||
import { FeedbackRecord } from '../types/monitoring';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoadingSpinner } from '@/components/ui/loading-spinner';
|
||||
|
||||
interface FeedbackListProps {
|
||||
feedback: FeedbackRecord[];
|
||||
loading?: boolean;
|
||||
onViewMessage?: (messageId: string) => void;
|
||||
}
|
||||
|
||||
export function FeedbackList({
|
||||
feedback,
|
||||
loading,
|
||||
onViewMessage,
|
||||
}: FeedbackListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expandedId, setExpandedId] = React.useState<string | null>(null);
|
||||
|
||||
const toggleExpand = (id: string) => {
|
||||
setExpandedId(expandedId === id ? null : id);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!feedback || feedback.length === 0) {
|
||||
return (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-base font-medium mb-2">
|
||||
{t('monitoring.feedback.noFeedback')}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t('monitoring.feedback.noFeedbackDescription')}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{feedback.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`border rounded-xl overflow-hidden hover:shadow-md transition-all duration-200 ${
|
||||
item.feedbackType === 'like'
|
||||
? 'border-green-200 dark:border-green-900'
|
||||
: 'border-red-200 dark:border-red-900'
|
||||
}`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className={`p-5 cursor-pointer transition-colors ${
|
||||
item.feedbackType === 'like'
|
||||
? 'hover:bg-green-50 dark:hover:bg-green-950/50 bg-green-50/50 dark:bg-green-950/30'
|
||||
: 'hover:bg-red-50 dark:hover:bg-red-950/50 bg-red-50/50 dark:bg-red-950/30'
|
||||
}`}
|
||||
onClick={() => toggleExpand(item.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start flex-1">
|
||||
{/* Expand Icon */}
|
||||
<div className="mr-3 mt-0.5">
|
||||
{expandedId === item.id ? (
|
||||
<ChevronDown
|
||||
className={`w-5 h-5 ${item.feedbackType === 'like' ? 'text-green-500' : 'text-red-500'}`}
|
||||
/>
|
||||
) : (
|
||||
<ChevronRight
|
||||
className={`w-5 h-5 ${item.feedbackType === 'like' ? 'text-green-500' : 'text-red-500'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{/* Feedback Type Icon */}
|
||||
{item.feedbackType === 'like' ? (
|
||||
<ThumbsUp className="w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
<ThumbsDown className="w-5 h-5 text-red-500" />
|
||||
)}
|
||||
<span
|
||||
className={`text-sm font-medium ${item.feedbackType === 'like' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}
|
||||
>
|
||||
{item.feedbackType === 'like'
|
||||
? t('monitoring.feedback.like')
|
||||
: t('monitoring.feedback.dislike')}
|
||||
</span>
|
||||
{item.botName && (
|
||||
<>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{item.botName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{item.platform && (
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400">
|
||||
{item.platform}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{item.feedbackContent && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 line-clamp-2">
|
||||
{item.feedbackContent}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{item.inaccurateReasons &&
|
||||
item.inaccurateReasons.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{item.inaccurateReasons.map((reason, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="text-xs px-2 py-0.5 rounded bg-red-100 dark:bg-red-900/30 text-red-600 dark:text-red-400"
|
||||
>
|
||||
{reason}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{item.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedId === item.id && (
|
||||
<div
|
||||
className={`border-t p-5 bg-white dark:bg-gray-900 ${
|
||||
item.feedbackType === 'like'
|
||||
? 'border-green-200 dark:border-green-900'
|
||||
: 'border-red-200 dark:border-red-900'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-4 pl-8 border-l-2 border-gray-200 dark:border-gray-700 ml-4">
|
||||
{/* Context Info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t('monitoring.feedback.contextInfo')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
{item.botName && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.messageList.bot')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.botName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.pipelineName && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.messageList.pipeline')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.pipelineName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.sessionId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.sessions.sessionId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.sessionId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.userId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.feedback.userId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.userId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.messageId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.feedback.messageId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate flex items-center gap-1">
|
||||
<span className="truncate">{item.messageId}</span>
|
||||
{onViewMessage && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-1.5 text-xs shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewMessage(item.messageId!);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{item.streamId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
{t('monitoring.feedback.streamId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{item.streamId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Feedback Content */}
|
||||
{item.feedbackContent && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
{t('monitoring.feedback.feedbackContent')}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
|
||||
{item.feedbackContent}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -76,7 +76,7 @@ export function MessageContentRenderer({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm"
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
>
|
||||
[Image]
|
||||
</span>
|
||||
@@ -87,8 +87,8 @@ export function MessageContentRenderer({
|
||||
<span key={index} className="inline-block align-middle mx-1">
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="Image"
|
||||
className="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity border border-gray-200 dark:border-gray-700"
|
||||
alt="Message attachment"
|
||||
className="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80 transition-opacity border"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPreviewImageUrl(imageUrl);
|
||||
@@ -104,7 +104,7 @@ export function MessageContentRenderer({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm"
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 mr-1"
|
||||
@@ -123,7 +123,7 @@ export function MessageContentRenderer({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm"
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
>
|
||||
<svg
|
||||
className="w-3.5 h-3.5 mr-1"
|
||||
@@ -142,7 +142,7 @@ export function MessageContentRenderer({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 text-sm border-l-2 border-gray-400"
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm border-l-2 border-muted-foreground/50"
|
||||
>
|
||||
{quote.origin
|
||||
?.filter((c) => (c as MessageChainComponent).type === 'Plain')
|
||||
@@ -159,7 +159,7 @@ export function MessageContentRenderer({
|
||||
return (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 text-sm"
|
||||
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
|
||||
>
|
||||
[{component.type}]
|
||||
</span>
|
||||
@@ -188,9 +188,7 @@ export function MessageContentRenderer({
|
||||
// If no visible components, show placeholder
|
||||
if (visibleComponents.length === 0) {
|
||||
return (
|
||||
<span className="text-gray-400 dark:text-gray-500 italic">
|
||||
[Empty message]
|
||||
</span>
|
||||
<span className="text-muted-foreground italic">[Empty message]</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -219,9 +217,7 @@ export function MessageContentRenderer({
|
||||
content === '""'
|
||||
) {
|
||||
return (
|
||||
<span className="text-gray-400 dark:text-gray-500 italic">
|
||||
[Empty message]
|
||||
</span>
|
||||
<span className="text-muted-foreground italic">[Empty message]</span>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,11 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
}, [details.message?.variables]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 pl-8 border-l-2 border-gray-200 dark:border-gray-700 ml-4">
|
||||
<div className="space-y-4 pl-8 border-l-2 border-border ml-4">
|
||||
{/* Context Info Section */}
|
||||
{details.message && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -41,37 +41,37 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
{/* Metadata Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
{details.message.platform && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.platform')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
<div className="font-medium text-foreground">
|
||||
{details.message.platform}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{details.message.userId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.user')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{details.message.userId}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{details.message.runnerName && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.runner')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
<div className="font-medium text-foreground">
|
||||
{details.message.runnerName}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.level')}
|
||||
</div>
|
||||
<div
|
||||
@@ -80,7 +80,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: details.message.level === 'warning'
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-gray-900 dark:text-white'
|
||||
: 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{details.message.level.toUpperCase()}
|
||||
@@ -92,8 +92,8 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
|
||||
{/* LLM Calls Section */}
|
||||
{details.llmCalls && details.llmCalls.length > 0 && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -136,13 +136,10 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
{/* Individual LLM Calls */}
|
||||
<div className="space-y-2">
|
||||
{details.llmCalls.map((call, index) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="bg-white dark:bg-gray-900 rounded p-2 text-sm"
|
||||
>
|
||||
<div key={call.id} className="bg-background rounded p-2 text-sm">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<span className="font-medium text-gray-900 dark:text-white">
|
||||
<span className="font-medium text-foreground">
|
||||
#{index + 1} {call.modelName}
|
||||
</span>
|
||||
<span
|
||||
@@ -155,27 +152,21 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
{call.status}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{call.duration}ms
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<div className="grid grid-cols-3 gap-2 text-xs text-muted-foreground">
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-500">
|
||||
In:
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">In:</span>{' '}
|
||||
{call.tokens.input.toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-500">
|
||||
Out:
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">Out:</span>{' '}
|
||||
{call.tokens.output.toLocaleString()}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500 dark:text-gray-500">
|
||||
Total:
|
||||
</span>{' '}
|
||||
<span className="text-muted-foreground">Total:</span>{' '}
|
||||
{call.tokens.total.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,7 +183,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
|
||||
{/* Errors Section */}
|
||||
{details.errors && details.errors.length > 0 && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3 flex items-center">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
@@ -236,8 +227,8 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
{queryVariables &&
|
||||
Object.keys(queryVariables).length > 0 &&
|
||||
details.message?.runnerName !== 'local-agent' && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3 flex items-center">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -250,22 +241,21 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs">
|
||||
{Object.entries(queryVariables).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="bg-white dark:bg-gray-900 rounded p-2"
|
||||
>
|
||||
<div className="text-gray-500 dark:text-gray-400">{key}</div>
|
||||
<div key={key} className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">{key}</div>
|
||||
<div
|
||||
className="font-medium text-gray-900 dark:text-white truncate"
|
||||
className="font-medium text-foreground truncate"
|
||||
title={
|
||||
typeof value === 'string' ? value : JSON.stringify(value)
|
||||
}
|
||||
>
|
||||
{value === null || value === undefined ? (
|
||||
<span className="text-gray-400 italic">null</span>
|
||||
<span className="text-muted-foreground italic">null</span>
|
||||
) : typeof value === 'string' ? (
|
||||
value || (
|
||||
<span className="text-gray-400 italic">empty</span>
|
||||
<span className="text-muted-foreground italic">
|
||||
empty
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
JSON.stringify(value)
|
||||
@@ -283,7 +273,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
|
||||
(details.message?.runnerName === 'local-agent' ||
|
||||
!queryVariables ||
|
||||
Object.keys(queryVariables).length === 0) && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 text-center py-4">
|
||||
<div className="text-sm text-muted-foreground text-center py-4">
|
||||
{t('monitoring.messageDetails.noData')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -114,7 +114,7 @@ export default function MonitoringFilters({
|
||||
<div className="flex flex-wrap items-center gap-6">
|
||||
{/* Bot Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
<label className="text-sm font-medium text-foreground whitespace-nowrap">
|
||||
{t('monitoring.filters.bot')}
|
||||
</label>
|
||||
<Select
|
||||
@@ -122,7 +122,7 @@ export default function MonitoringFilters({
|
||||
onValueChange={handleBotChange}
|
||||
disabled={loadingBots}
|
||||
>
|
||||
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[140px]">
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingBots
|
||||
@@ -146,7 +146,7 @@ export default function MonitoringFilters({
|
||||
|
||||
{/* Pipeline Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
<label className="text-sm font-medium text-foreground whitespace-nowrap">
|
||||
{t('monitoring.filters.pipeline')}
|
||||
</label>
|
||||
<Select
|
||||
@@ -154,7 +154,7 @@ export default function MonitoringFilters({
|
||||
onValueChange={handlePipelineChange}
|
||||
disabled={loadingPipelines}
|
||||
>
|
||||
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[140px]">
|
||||
<SelectTrigger className="h-9 w-[140px]">
|
||||
<SelectValue
|
||||
placeholder={
|
||||
loadingPipelines
|
||||
@@ -178,11 +178,11 @@ export default function MonitoringFilters({
|
||||
|
||||
{/* Time Range Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
|
||||
<label className="text-sm font-medium text-foreground whitespace-nowrap">
|
||||
{t('monitoring.filters.timeRange')}
|
||||
</label>
|
||||
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
|
||||
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[150px]">
|
||||
<SelectTrigger className="h-9 w-[150px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@@ -23,9 +23,9 @@ export default function MetricCard({
|
||||
}: MetricCardProps) {
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-all duration-300">
|
||||
<Card className="transition-all duration-300">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center">
|
||||
@@ -35,17 +35,17 @@ export default function MetricCard({
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-9 w-28 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div>
|
||||
<div className="h-4 w-20 bg-gray-100 dark:bg-gray-800 animate-pulse rounded mt-2"></div>
|
||||
<div className="h-9 w-28 bg-muted animate-pulse rounded"></div>
|
||||
<div className="h-4 w-20 bg-muted animate-pulse rounded mt-2"></div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white dark:bg-[#2a2a2e] border-gray-200 dark:border-gray-700 shadow-sm hover:shadow-md transition-all duration-300 group">
|
||||
<Card className="transition-all duration-300 group">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||
<CardTitle className="text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{title}
|
||||
</CardTitle>
|
||||
<div className="h-10 w-10 rounded-lg bg-gradient-to-br from-blue-50 to-blue-100 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center group-hover:scale-110 transition-transform duration-300">
|
||||
@@ -53,9 +53,7 @@ export default function MetricCard({
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
{value}
|
||||
</div>
|
||||
<div className="text-3xl font-bold text-foreground mb-2">{value}</div>
|
||||
{trend && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
@@ -82,7 +80,7 @@ export default function MetricCard({
|
||||
</svg>
|
||||
{Math.abs(trend.value)}%
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
vs previous period
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -126,16 +126,16 @@ export default function TrafficChart({
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6">
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div>
|
||||
<div className="h-5 w-32 bg-muted animate-pulse rounded"></div>
|
||||
<div className="flex gap-4">
|
||||
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div>
|
||||
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-700 animate-pulse rounded"></div>
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
|
||||
<div className="h-4 w-24 bg-muted animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<div className="animate-pulse w-full h-full bg-gray-100 dark:bg-gray-800 rounded"></div>
|
||||
<div className="animate-pulse w-full h-full bg-muted rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -143,35 +143,28 @@ export default function TrafficChart({
|
||||
|
||||
if (chartData.length === 0) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6">
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 mb-4">
|
||||
<div className="bg-card rounded-xl border p-6">
|
||||
<h3 className="text-base font-semibold text-foreground mb-4">
|
||||
{t('monitoring.trafficChart.title')}
|
||||
</h3>
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
|
||||
<div className="h-[300px] flex flex-col items-center justify-center text-muted-foreground gap-2">
|
||||
<svg
|
||||
className="w-16 h-16 mb-4 text-gray-300 dark:text-gray-600"
|
||||
fill="none"
|
||||
className="h-[3rem] w-[3rem]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
|
||||
/>
|
||||
<path d="M2 13H8V21H2V13ZM16 8H22V21H16V8ZM9 3H15V21H9V3ZM4 15V19H6V15H4ZM11 5V19H13V5H11ZM18 10V19H20V10H18Z"></path>
|
||||
</svg>
|
||||
<p className="text-sm font-medium">
|
||||
{t('monitoring.trafficChart.noData')}
|
||||
</p>
|
||||
<div className="text-sm">{t('monitoring.trafficChart.noData')}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm p-6 hover:shadow-md transition-shadow duration-300">
|
||||
<h3 className="text-base font-semibold text-gray-800 dark:text-gray-200 mb-6">
|
||||
<div className="bg-card rounded-xl border p-6 transition-shadow duration-300">
|
||||
<h3 className="text-base font-semibold text-foreground mb-6">
|
||||
{t('monitoring.trafficChart.title')}
|
||||
</h3>
|
||||
<div className="h-[300px]">
|
||||
@@ -192,38 +185,38 @@ export default function TrafficChart({
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
stroke="#e5e7eb"
|
||||
className="dark:stroke-gray-700"
|
||||
stroke="var(--border)"
|
||||
vertical={false}
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 12, fill: '#9ca3af' }}
|
||||
tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
axisLine={{ stroke: 'var(--border)' }}
|
||||
dy={10}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#9ca3af' }}
|
||||
tick={{ fontSize: 12, fill: 'var(--muted-foreground)' }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: '#e5e7eb' }}
|
||||
axisLine={{ stroke: 'var(--border)' }}
|
||||
width={40}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.98)',
|
||||
border: '1px solid #e5e7eb',
|
||||
backgroundColor: 'var(--card)',
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: '12px',
|
||||
boxShadow:
|
||||
'0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||
fontSize: '13px',
|
||||
padding: '12px',
|
||||
color: 'var(--foreground)',
|
||||
}}
|
||||
labelStyle={{
|
||||
fontWeight: 600,
|
||||
marginBottom: '8px',
|
||||
color: '#374151',
|
||||
color: 'var(--foreground)',
|
||||
}}
|
||||
itemStyle={{ padding: '4px 0' }}
|
||||
/>
|
||||
|
||||
192
web/src/app/home/monitoring/hooks/useFeedbackData.ts
Normal file
192
web/src/app/home/monitoring/hooks/useFeedbackData.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { httpClient } from '@/app/infra/http';
|
||||
import { FeedbackRecord, FeedbackStats } from '../types/monitoring';
|
||||
|
||||
interface UseFeedbackDataParams {
|
||||
botIds?: string[];
|
||||
pipelineIds?: string[];
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
feedbackType?: 'like' | 'dislike';
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
interface RawFeedbackRecord {
|
||||
id: string;
|
||||
timestamp: string;
|
||||
feedback_id: string;
|
||||
feedback_type: number;
|
||||
feedback_content?: string;
|
||||
inaccurate_reasons?: string;
|
||||
bot_id?: string;
|
||||
bot_name?: string;
|
||||
pipeline_id?: string;
|
||||
pipeline_name?: string;
|
||||
session_id?: string;
|
||||
message_id?: string;
|
||||
stream_id?: string;
|
||||
user_id?: string;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
interface RawFeedbackStats {
|
||||
total_feedback: number;
|
||||
total_likes: number;
|
||||
total_dislikes: number;
|
||||
satisfaction_rate: number;
|
||||
by_bot?: Array<{
|
||||
bot_id: string;
|
||||
bot_name: string;
|
||||
total: number;
|
||||
likes: number;
|
||||
dislikes: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook for fetching and managing feedback data
|
||||
*/
|
||||
export function useFeedbackData(params: UseFeedbackDataParams = {}) {
|
||||
const [feedback, setFeedback] = useState<FeedbackRecord[]>([]);
|
||||
const [stats, setStats] = useState<FeedbackStats | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const paramsStr = useMemo(() => JSON.stringify(params), [params]);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.botIds) {
|
||||
params.botIds.forEach((id) => queryParams.append('botId', id));
|
||||
}
|
||||
if (params.pipelineIds) {
|
||||
params.pipelineIds.forEach((id) =>
|
||||
queryParams.append('pipelineId', id),
|
||||
);
|
||||
}
|
||||
if (params.startTime) {
|
||||
queryParams.append('startTime', params.startTime);
|
||||
}
|
||||
if (params.endTime) {
|
||||
queryParams.append('endTime', params.endTime);
|
||||
}
|
||||
|
||||
const result = await httpClient.get<RawFeedbackStats>(
|
||||
`/api/v1/monitoring/feedback/stats?${queryParams.toString()}`,
|
||||
);
|
||||
|
||||
if (result) {
|
||||
setStats({
|
||||
totalFeedback: result.total_feedback,
|
||||
totalLikes: result.total_likes,
|
||||
totalDislikes: result.total_dislikes,
|
||||
satisfactionRate: result.satisfaction_rate,
|
||||
byBot: result.by_bot?.map((bot) => ({
|
||||
botId: bot.bot_id,
|
||||
botName: bot.bot_name,
|
||||
totalFeedback: bot.total,
|
||||
totalLikes: bot.likes,
|
||||
totalDislikes: bot.dislikes,
|
||||
satisfactionRate:
|
||||
bot.total > 0 ? Math.round((bot.likes / bot.total) * 100) : 0,
|
||||
})),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch feedback stats:', err);
|
||||
}
|
||||
}, [params.botIds, params.pipelineIds, params.startTime, params.endTime]);
|
||||
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.botIds) {
|
||||
params.botIds.forEach((id) => queryParams.append('botId', id));
|
||||
}
|
||||
if (params.pipelineIds) {
|
||||
params.pipelineIds.forEach((id) =>
|
||||
queryParams.append('pipelineId', id),
|
||||
);
|
||||
}
|
||||
if (params.startTime) {
|
||||
queryParams.append('startTime', params.startTime);
|
||||
}
|
||||
if (params.endTime) {
|
||||
queryParams.append('endTime', params.endTime);
|
||||
}
|
||||
if (params.feedbackType) {
|
||||
queryParams.append(
|
||||
'feedbackType',
|
||||
params.feedbackType === 'like' ? '1' : '2',
|
||||
);
|
||||
}
|
||||
if (params.limit) {
|
||||
queryParams.append('limit', params.limit.toString());
|
||||
}
|
||||
if (params.offset) {
|
||||
queryParams.append('offset', params.offset.toString());
|
||||
}
|
||||
|
||||
const result = await httpClient.get<{
|
||||
feedback: RawFeedbackRecord[];
|
||||
total: number;
|
||||
}>(`/api/v1/monitoring/feedback?${queryParams.toString()}`);
|
||||
|
||||
if (result) {
|
||||
const transformedFeedback: FeedbackRecord[] = result.feedback.map(
|
||||
(item) => ({
|
||||
id: item.id,
|
||||
timestamp: new Date(item.timestamp),
|
||||
feedbackId: item.feedback_id,
|
||||
feedbackType: item.feedback_type === 1 ? 'like' : 'dislike',
|
||||
feedbackContent: item.feedback_content,
|
||||
inaccurateReasons: item.inaccurate_reasons
|
||||
? JSON.parse(item.inaccurate_reasons)
|
||||
: undefined,
|
||||
botId: item.bot_id,
|
||||
botName: item.bot_name,
|
||||
pipelineId: item.pipeline_id,
|
||||
pipelineName: item.pipeline_name,
|
||||
sessionId: item.session_id,
|
||||
messageId: item.message_id,
|
||||
streamId: item.stream_id,
|
||||
userId: item.user_id,
|
||||
platform: item.platform,
|
||||
}),
|
||||
);
|
||||
|
||||
setFeedback(transformedFeedback);
|
||||
setTotal(result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
console.error('Failed to fetch feedback:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [params]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
fetchStats();
|
||||
fetchFeedback();
|
||||
}, [fetchStats, fetchFeedback]);
|
||||
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [paramsStr]);
|
||||
|
||||
return {
|
||||
feedback,
|
||||
stats,
|
||||
total,
|
||||
loading,
|
||||
error,
|
||||
refetch,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import React, { Suspense, useState } from 'react';
|
||||
import React, { Suspense, useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -10,8 +10,11 @@ import MonitoringFilters from './components/filters/MonitoringFilters';
|
||||
import { ExportDropdown } from './components/ExportDropdown';
|
||||
import { useMonitoringFilters } from './hooks/useMonitoringFilters';
|
||||
import { useMonitoringData } from './hooks/useMonitoringData';
|
||||
import { useFeedbackData } from './hooks/useFeedbackData';
|
||||
import { MessageDetailsCard } from './components/MessageDetailsCard';
|
||||
import { MessageContentRenderer } from './components/MessageContentRenderer';
|
||||
import { FeedbackStatsCards } from './components/FeedbackCard';
|
||||
import { FeedbackList } from './components/FeedbackList';
|
||||
import { MessageDetails } from './types/monitoring';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
|
||||
@@ -68,6 +71,64 @@ function MonitoringPageContent() {
|
||||
useMonitoringFilters();
|
||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||
|
||||
// Get time range for feedback data
|
||||
const feedbackTimeRange = useMemo(() => {
|
||||
const now = new Date();
|
||||
let startTime: Date | null = null;
|
||||
|
||||
switch (filterState.timeRange) {
|
||||
case 'lastHour':
|
||||
startTime = new Date(now.getTime() - 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last6Hours':
|
||||
startTime = new Date(now.getTime() - 6 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last24Hours':
|
||||
startTime = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last7Days':
|
||||
startTime = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'last30Days':
|
||||
startTime = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
break;
|
||||
case 'custom':
|
||||
if (filterState.customDateRange) {
|
||||
startTime = filterState.customDateRange.from;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const endTime =
|
||||
filterState.timeRange === 'custom' && filterState.customDateRange
|
||||
? filterState.customDateRange.to
|
||||
: now;
|
||||
|
||||
return {
|
||||
startTime: startTime?.toISOString(),
|
||||
endTime: endTime.toISOString(),
|
||||
};
|
||||
}, [filterState.timeRange, filterState.customDateRange]);
|
||||
|
||||
// Feedback data hook
|
||||
const {
|
||||
feedback: feedbackList,
|
||||
stats: feedbackStats,
|
||||
loading: feedbackLoading,
|
||||
} = useFeedbackData({
|
||||
botIds:
|
||||
filterState.selectedBots.length > 0
|
||||
? filterState.selectedBots
|
||||
: undefined,
|
||||
pipelineIds:
|
||||
filterState.selectedPipelines.length > 0
|
||||
? filterState.selectedPipelines
|
||||
: undefined,
|
||||
startTime: feedbackTimeRange.startTime,
|
||||
endTime: feedbackTimeRange.endTime,
|
||||
limit: 50,
|
||||
});
|
||||
|
||||
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
@@ -188,11 +249,11 @@ function MonitoringPageContent() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-full">
|
||||
<div className="w-full h-full overflow-y-auto overflow-x-hidden">
|
||||
{/* Filters and Refresh Button - Sticky */}
|
||||
<div className="sticky top-[-1.5rem] z-10 -ml-[2rem] -mr-[1.5rem] -mt-[1.5rem] pt-[1.5rem] pb-4 bg-[#fafafa] dark:bg-[#151518]">
|
||||
<div className="sticky top-[-1.5rem] z-10 -ml-[2rem] -mr-[1.5rem] -mt-[1.5rem] pt-[1.5rem] pb-4 bg-background">
|
||||
<div className="ml-[2rem] mr-[1.5rem] px-[0.8rem]">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-card rounded-xl border">
|
||||
<MonitoringFilters
|
||||
selectedBots={filterState.selectedBots}
|
||||
selectedPipelines={filterState.selectedPipelines}
|
||||
@@ -207,7 +268,7 @@ function MonitoringPageContent() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={refetch}
|
||||
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600 shadow-sm flex-shrink-0"
|
||||
className="shadow-sm flex-shrink-0"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
@@ -235,30 +296,24 @@ function MonitoringPageContent() {
|
||||
/>
|
||||
|
||||
{/* Tabs Section */}
|
||||
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm overflow-hidden">
|
||||
<div className="bg-card rounded-xl border overflow-hidden">
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="w-full"
|
||||
>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="bg-gray-100 dark:bg-[#1a1a1e] h-12 p-1">
|
||||
<TabsTrigger
|
||||
value="messages"
|
||||
className="px-6 py-2 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm"
|
||||
>
|
||||
<TabsList className="h-12 p-1">
|
||||
<TabsTrigger value="messages" className="px-6 py-2">
|
||||
{t('monitoring.tabs.messages')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="modelCalls"
|
||||
className="px-6 py-2 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm"
|
||||
>
|
||||
<TabsTrigger value="modelCalls" className="px-6 py-2">
|
||||
{t('monitoring.tabs.modelCalls')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="errors"
|
||||
className="px-6 py-2 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm"
|
||||
>
|
||||
<TabsTrigger value="feedback" className="px-6 py-2">
|
||||
{t('monitoring.tabs.feedback')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="errors" className="px-6 py-2">
|
||||
{t('monitoring.tabs.errors')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -290,11 +345,11 @@ function MonitoringPageContent() {
|
||||
.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden hover:shadow-md transition-all duration-200"
|
||||
className="border rounded-xl overflow-hidden transition-all duration-200"
|
||||
>
|
||||
{/* Message Header - Always Visible */}
|
||||
<div
|
||||
className="p-5 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
|
||||
className="p-5 cursor-pointer hover:bg-accent transition-colors"
|
||||
onClick={() => toggleMessageExpand(msg.id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
@@ -302,39 +357,41 @@ function MonitoringPageContent() {
|
||||
{/* Expand Icon */}
|
||||
<div className="mr-3 mt-0.5">
|
||||
{expandedMessageId === msg.id ? (
|
||||
<ChevronDown className="w-5 h-5 text-gray-500" />
|
||||
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-5 h-5 text-gray-500" />
|
||||
<ChevronRight className="w-5 h-5 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message Info */}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
ID: {msg.id}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="font-medium text-sm text-gray-700 dark:text-gray-300">
|
||||
<span className="font-medium text-sm text-foreground">
|
||||
{msg.botName}
|
||||
</span>
|
||||
<span className="text-gray-400">→</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-muted-foreground">
|
||||
→
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{msg.pipelineName}
|
||||
</span>
|
||||
{msg.runnerName && (
|
||||
<>
|
||||
<span className="text-gray-400">
|
||||
<span className="text-muted-foreground">
|
||||
→
|
||||
</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{msg.runnerName}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-base text-gray-800 dark:text-gray-200">
|
||||
<div className="text-base text-foreground">
|
||||
<MessageContentRenderer
|
||||
content={msg.messageContent}
|
||||
maxLines={3}
|
||||
@@ -345,7 +402,7 @@ function MonitoringPageContent() {
|
||||
|
||||
{/* Status and Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{msg.timestamp.toLocaleString()}
|
||||
</span>
|
||||
<span
|
||||
@@ -365,7 +422,7 @@ function MonitoringPageContent() {
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedMessageId === msg.id && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="border-t p-4 bg-muted">
|
||||
{loadingDetails[msg.id] && (
|
||||
<div className="py-4 flex justify-center">
|
||||
<LoadingSpinner size="sm" text="" />
|
||||
@@ -386,26 +443,18 @@ function MonitoringPageContent() {
|
||||
|
||||
{!loading &&
|
||||
(!data || !data.messages || data.messages.length === 0) && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
|
||||
fill="none"
|
||||
className="h-[3rem] w-[3rem]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"
|
||||
/>
|
||||
<path d="M6.45455 19L2 22.5V4C2 3.44772 2.44772 3 3 3H21C21.5523 3 22 3.44772 22 4V18C22 18.5523 21.5523 19 21 19H6.45455ZM4 18.3851L5.76282 17H20V5H4V18.3851Z"></path>
|
||||
</svg>
|
||||
<p className="text-base font-medium mb-2">
|
||||
<div className="text-sm">
|
||||
{t('monitoring.messageList.noMessages')}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
{t('monitoring.messageList.noMessagesDescription')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -427,14 +476,14 @@ function MonitoringPageContent() {
|
||||
{data.modelCalls.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-xl p-5 hover:shadow-md transition-all duration-200"
|
||||
className="border rounded-xl p-5 transition-all duration-200"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<div className="flex-1">
|
||||
{/* Query ID - only show if messageId exists */}
|
||||
{call.messageId && (
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {call.messageId}
|
||||
</span>
|
||||
<Button
|
||||
@@ -496,19 +545,19 @@ function MonitoringPageContent() {
|
||||
</span>
|
||||
</div>
|
||||
{/* Model Name */}
|
||||
<div className="font-medium text-sm text-gray-700 dark:text-gray-300 mb-2">
|
||||
<div className="font-medium text-sm text-foreground mb-2">
|
||||
{call.modelName}
|
||||
</div>
|
||||
{/* Context Info - only for LLM calls */}
|
||||
{call.modelType === 'llm' &&
|
||||
call.botName &&
|
||||
call.pipelineName && (
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mb-1">
|
||||
<div className="text-xs text-muted-foreground mb-1">
|
||||
{call.botName} → {call.pipelineName}
|
||||
</div>
|
||||
)}
|
||||
{/* Token Info */}
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<div className="text-xs text-muted-foreground space-y-1">
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{call.modelType === 'llm' && call.tokens && (
|
||||
<>
|
||||
@@ -572,14 +621,14 @@ function MonitoringPageContent() {
|
||||
{/* Query Text for Embedding Retrieve */}
|
||||
{call.modelType === 'embedding' &&
|
||||
call.queryText && (
|
||||
<div className="mt-2 p-2 bg-gray-50 dark:bg-gray-800 rounded text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
<div className="mt-2 p-2 bg-muted rounded text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t(
|
||||
'monitoring.embeddingCalls.queryText',
|
||||
)}
|
||||
:{' '}
|
||||
</span>
|
||||
<span className="text-gray-700 dark:text-gray-300">
|
||||
<span className="text-foreground">
|
||||
{call.queryText.length > 100
|
||||
? call.queryText.substring(0, 100) +
|
||||
'...'
|
||||
@@ -594,7 +643,7 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap ml-4">
|
||||
{call.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -607,28 +656,55 @@ function MonitoringPageContent() {
|
||||
(!data ||
|
||||
!data.modelCalls ||
|
||||
data.modelCalls.length === 0) && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600"
|
||||
fill="none"
|
||||
className="h-[3rem] w-[3rem]"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
/>
|
||||
<path d="M10.6144 17.7956C10.277 18.5682 9.20776 18.5682 8.8704 17.7956L7.99275 15.7854C7.21171 13.9966 5.80589 12.5726 4.0523 11.7942L1.63658 10.7219C.868536 10.381.868537 9.26368 1.63658 8.92276L3.97685 7.88394C5.77553 7.08552 7.20657 5.60881 7.97427 3.75892L8.8633 1.61673C9.19319.821767 10.2916.821765 10.6215 1.61673L11.5105 3.75894C12.2782 5.60881 13.7092 7.08552 15.5079 7.88394L17.8482 8.92276C18.6162 9.26368 18.6162 10.381 17.8482 10.7219L15.4325 11.7942C13.6789 12.5726 12.2731 13.9966 11.492 15.7854L10.6144 17.7956ZM19.4014 22.6899 19.6482 22.1242C20.0882 21.1156 20.8807 20.3125 21.8695 19.8732L22.6299 19.5353C23.0412 19.3526 23.0412 18.7549 22.6299 18.5722L21.9121 18.2532C20.8978 17.8026 20.0911 16.9698 19.6586 15.9269L19.4052 15.3156C19.2285 14.8896 18.6395 14.8896 18.4628 15.3156L18.2094 15.9269C17.777 16.9698 16.9703 17.8026 15.956 18.2532L15.2381 18.5722C14.8269 18.7549 14.8269 19.3526 15.2381 19.5353L15.9985 19.8732C16.9874 20.3125 17.7798 21.1156 18.2198 22.1242L18.4667 22.6899C18.6473 23.104 19.2207 23.104 19.4014 22.6899Z"></path>
|
||||
</svg>
|
||||
<p className="text-base font-medium">
|
||||
<div className="text-sm">
|
||||
{t('monitoring.modelCalls.noData')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="feedback" className="p-6 m-0">
|
||||
<div>
|
||||
{loading && (
|
||||
<div className="py-12 flex justify-center">
|
||||
<LoadingSpinner text={t('common.loading')} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<>
|
||||
{/* Feedback Stats Cards */}
|
||||
<div className="mb-6">
|
||||
<FeedbackStatsCards
|
||||
stats={feedbackStats}
|
||||
loading={feedbackLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Feedback List */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
{t('monitoring.feedback.feedbackList')}
|
||||
</h3>
|
||||
<FeedbackList
|
||||
feedback={feedbackList}
|
||||
loading={feedbackLoading}
|
||||
onViewMessage={jumpToMessage}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="errors" className="p-6 m-0">
|
||||
<div>
|
||||
{loading && (
|
||||
@@ -642,7 +718,7 @@ function MonitoringPageContent() {
|
||||
{data.errors.map((error) => (
|
||||
<div
|
||||
key={error.id}
|
||||
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden hover:shadow-md transition-all duration-200"
|
||||
className="border border-red-200 dark:border-red-900 rounded-xl overflow-hidden transition-all duration-200"
|
||||
>
|
||||
{/* Error Header - Always Visible */}
|
||||
<div
|
||||
@@ -664,7 +740,7 @@ function MonitoringPageContent() {
|
||||
<div className="flex-1">
|
||||
{/* Query ID */}
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 font-mono">
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
Query ID: {error.messageId || '-'}
|
||||
</span>
|
||||
{error.messageId && (
|
||||
@@ -689,11 +765,11 @@ function MonitoringPageContent() {
|
||||
{error.errorType}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.botName}
|
||||
</span>
|
||||
<span className="text-red-400">→</span>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{error.pipelineName}
|
||||
</span>
|
||||
</div>
|
||||
@@ -705,7 +781,7 @@ function MonitoringPageContent() {
|
||||
|
||||
{/* Timestamp */}
|
||||
<div className="flex flex-col items-end gap-2 ml-4">
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
{error.timestamp.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
@@ -714,7 +790,7 @@ function MonitoringPageContent() {
|
||||
|
||||
{/* Expanded Details */}
|
||||
{expandedErrorId === error.id && (
|
||||
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-white dark:bg-gray-900">
|
||||
<div className="border-t border-red-200 dark:border-red-900 p-5 bg-background">
|
||||
<div className="space-y-4 pl-8 border-l-2 border-red-300 dark:border-red-800 ml-4">
|
||||
{/* Error Details */}
|
||||
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
|
||||
@@ -727,33 +803,33 @@ function MonitoringPageContent() {
|
||||
</div>
|
||||
|
||||
{/* Context Info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.messageList.viewDetails')}
|
||||
</h4>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs">
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.bot')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
<div className="font-medium text-foreground">
|
||||
{error.botName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.messageList.pipeline')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white">
|
||||
<div className="font-medium text-foreground">
|
||||
{error.pipelineName}
|
||||
</div>
|
||||
</div>
|
||||
{error.sessionId && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded p-2">
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<div className="bg-background rounded p-2">
|
||||
<div className="text-muted-foreground">
|
||||
{t('monitoring.sessions.sessionId')}
|
||||
</div>
|
||||
<div className="font-medium text-gray-900 dark:text-white truncate">
|
||||
<div className="font-medium text-foreground truncate">
|
||||
{error.sessionId}
|
||||
</div>
|
||||
</div>
|
||||
@@ -763,11 +839,11 @@ function MonitoringPageContent() {
|
||||
|
||||
{/* Stack Trace */}
|
||||
{error.stackTrace && (
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">
|
||||
<div className="bg-muted rounded-lg p-3">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
{t('monitoring.errors.stackTrace')}
|
||||
</h4>
|
||||
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-60 bg-white dark:bg-gray-900 p-3 rounded whitespace-pre-wrap break-words">
|
||||
<pre className="text-xs text-muted-foreground overflow-auto max-h-60 bg-background p-3 rounded whitespace-pre-wrap break-words">
|
||||
{error.stackTrace}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -782,23 +858,18 @@ function MonitoringPageContent() {
|
||||
|
||||
{!loading &&
|
||||
(!data || !data.errors || data.errors.length === 0) && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||
<svg
|
||||
className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600"
|
||||
fill="none"
|
||||
className="h-[3rem] w-[3rem] text-green-500 dark:text-green-600"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
<path d="M12 22C6.47715 22 2 17.5228 2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22ZM12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20ZM11.0026 16L6.75999 11.7574L8.17421 10.3431L11.0026 13.1716L16.6595 7.51472L18.0737 8.92893L11.0026 16Z"></path>
|
||||
</svg>
|
||||
<p className="text-base font-medium text-green-600 dark:text-green-400">
|
||||
<div className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('monitoring.errors.noErrors')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -162,6 +162,39 @@ export interface DateRange {
|
||||
to: Date;
|
||||
}
|
||||
|
||||
export interface FeedbackRecord {
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
feedbackId: string;
|
||||
feedbackType: 'like' | 'dislike';
|
||||
feedbackContent?: string;
|
||||
inaccurateReasons?: string[];
|
||||
botId?: string;
|
||||
botName?: string;
|
||||
pipelineId?: string;
|
||||
pipelineName?: string;
|
||||
sessionId?: string;
|
||||
messageId?: string;
|
||||
streamId?: string;
|
||||
userId?: string;
|
||||
platform?: string;
|
||||
}
|
||||
|
||||
export interface FeedbackStats {
|
||||
totalFeedback: number;
|
||||
totalLikes: number;
|
||||
totalDislikes: number;
|
||||
satisfactionRate: number;
|
||||
byBot?: Array<{
|
||||
botId: string;
|
||||
botName: string;
|
||||
totalFeedback: number;
|
||||
totalLikes: number;
|
||||
totalDislikes: number;
|
||||
satisfactionRate: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MonitoringData {
|
||||
overview: OverviewMetrics;
|
||||
messages: MonitoringMessage[];
|
||||
@@ -170,11 +203,14 @@ export interface MonitoringData {
|
||||
modelCalls: ModelCall[];
|
||||
sessions: SessionInfo[];
|
||||
errors: ErrorLog[];
|
||||
feedback?: FeedbackRecord[];
|
||||
feedbackStats?: FeedbackStats;
|
||||
totalCount: {
|
||||
messages: number;
|
||||
llmCalls: number;
|
||||
embeddingCalls: number;
|
||||
sessions: number;
|
||||
errors: number;
|
||||
feedback?: number;
|
||||
};
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user