Merge branch 'langbot-app:master' into master

This commit is contained in:
Typer_Body
2026-02-12 02:40:57 +08:00
committed by GitHub
99 changed files with 25232 additions and 1014 deletions

1
.gitignore vendored
View File

@@ -42,7 +42,6 @@ botpy.log*
test.py
/web_ui
.venv/
uv.lock
/test
plugins.bak
coverage.xml

View File

@@ -88,11 +88,12 @@ docker compose up -d
<img width="500" src="https://docs.langbot.app/ui/bot-page-zh-rounded.png" />
- 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态、流式输出能力自带 RAG知识库实现并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)等 LLMOps 平台。
- 💬 大模型对话、Agent支持多种大模型适配群聊和私聊具有多轮对话、工具调用、多模态、流式输出能力自带 RAG知识库实现并深度适配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)等 LLMOps 平台。
- 🤖 多平台支持:目前支持 QQ、QQ频道、企业微信、个人微信、飞书、Discord、Telegram、KOOK、Slack、LINE 等平台。
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。支持多流水线配置,不同机器人用于不同应用场景。
- 🛠️ 高稳定性、功能完备:原生支持访问控制、限速、敏感词过滤等机制;配置简单,支持多种部署方式。
- 🧩 插件扩展、活跃社区:高稳定性、高安全性的生产级插件系统,支持事件驱动、组件扩展等插件机制;适配 Anthropic [MCP 协议](https://modelcontextprotocol.io/);目前已有数百个插件。
- 😻 Web 管理面板:支持通过浏览器管理 LangBot 实例,不再需要手动编写配置文件
- 😻 Web 管理面板:提供先进的 WebUI 管理面板,用最直观的方式配置、管理、监控机器人
- 📊 生产级特性:支持多流水线配置,不同机器人用于不同应用场景。具有全面的监控和异常处理能力。已被多家企业采用。
详细规格特性请访问[文档](https://docs.langbot.app/zh/insight/features.html)。

View File

@@ -85,11 +85,12 @@ Click the Star and Watch button in the upper right corner of the repository to g
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, multi-modal, and streaming output capabilities. Built-in RAG (knowledge base) implementation, and deeply integrates with [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 💬 Chat with LLM / Agent: Supports multiple LLMs, adapt to group chats and private chats; Supports multi-round conversations, tool calls, multi-modal, and streaming output capabilities. Built-in RAG (knowledge base) implementation, and deeply integrates with [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) etc. LLMOps platforms.
- 🤖 Multi-platform Support: Currently supports QQ, QQ Channel, WeCom, personal WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods. Supports multiple pipeline configurations, different bots can be used for different scenarios.
- 🛠️ High Stability, Feature-rich: Native access control, rate limiting, sensitive word filtering, etc. mechanisms; Easy to use, supports multiple deployment methods.
- 🧩 Plugin Extension, Active Community: High stability, high security production-level plugin system; Support event-driven, component extension, etc. plugin mechanisms; Integrate Anthropic [MCP protocol](https://modelcontextprotocol.io/); Currently has hundreds of plugins.
- 😻 Web UI: Support management LangBot instance through the browser. No need to manually write configuration files.
- 📊 Production-grade Features: Supports multiple pipeline configurations, different bots can be used for different scenarios. Has comprehensive monitoring and exception handling capabilities.
For more detailed specifications, please refer to the [documentation](https://docs.langbot.app/en/insight/features.html).

View File

@@ -85,11 +85,12 @@ Haga clic en los botones Star y Watch en la esquina superior derecha del reposit
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Chat con LLM / Agent: Compatible con múltiples LLMs, adaptado para chats grupales y privados; Admite conversaciones de múltiples rondas, llamadas a herramientas, capacidades multimodales y de salida en streaming. Implementación RAG (base de conocimientos) incorporada, e integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 💬 Chat con LLM / Agent: Compatible con múltiples LLMs, adaptado para chats grupales y privados; Admite conversaciones de múltiples rondas, llamadas a herramientas, capacidades multimodales y de salida en streaming. Implementación RAG (base de conocimientos) incorporada, e integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) etc. LLMOps platforms.
- 🤖 Soporte Multiplataforma: Actualmente compatible con QQ, QQ Channel, WeCom, WeChat personal, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
- 🛠️ Alta Estabilidad, Rico en Funciones: Control de acceso nativo, limitación de velocidad, filtrado de palabras sensibles, etc.; Fácil de usar, admite múltiples métodos de despliegue. Compatible con múltiples configuraciones de pipeline, diferentes bots para diferentes escenarios.
- 🛠️ Alta Estabilidad, Rico en Funciones: Control de acceso nativo, limitación de velocidad, filtrado de palabras sensibles, etc.; Fácil de usar, admite múltiples métodos de despliegue.
- 🧩 Extensión de Plugin, Comunidad Activa: Sistema de plugin de alta estabilidad, alta seguridad de nivel de producción; Compatible con mecanismos de plugin impulsados por eventos, extensión de componentes, etc.; Integración del protocolo [MCP](https://modelcontextprotocol.io/) de Anthropic; Actualmente cuenta con cientos de plugins.
- 😻 Interfaz Web: Admite la gestión de instancias de LangBot a través del navegador. No es necesario escribir archivos de configuración manualmente.
- 📊 Características de Nivel de Producción: Compatible con múltiples configuraciones de pipeline, diferentes bots para diferentes escenarios. Cuenta con capacidades completas de monitoreo y manejo de excepciones.
Para especificaciones más detalladas, consulte la [documentación](https://docs.langbot.app/en/insight/features.html).

View File

@@ -84,11 +84,12 @@ Cliquez sur les boutons Star et Watch dans le coin supérieur droit du dépôt p
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Chat avec LLM / Agent : Prend en charge plusieurs LLM, adapté aux chats de groupe et privés ; Prend en charge les conversations multi-tours, les appels d'outils, les capacités multimodales et de sortie en streaming. Implémentation RAG (base de connaissances) intégrée, et intégration profonde avec [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) etc. LLMOps platforms.
- 💬 Chat avec LLM / Agent : Prend en charge plusieurs LLM, adapté aux chats de groupe et privés ; Prend en charge les conversations multi-tours, les appels d'outils, les capacités multimodales et de sortie en streaming. Implémentation RAG (base de connaissances) intégrée, et intégration profonde avec [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) etc. LLMOps platforms.
- 🤖 Support Multi-plateforme : Actuellement compatible avec QQ, QQ Channel, WeCom, WeChat personnel, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, etc.
- 🛠️ Haute Stabilité, Riche en Fonctionnalités : Contrôle d'accès natif, limitation de débit, filtrage de mots sensibles, etc. ; Facile à utiliser, prend en charge plusieurs méthodes de déploiement. Prend en charge plusieurs configurations de pipeline, différents bots pour différents scénarios.
- 🛠️ Haute Stabilité, Riche en Fonctionnalités : Contrôle d'accès natif, limitation de débit, filtrage de mots sensibles, etc. ; Facile à utiliser, prend en charge plusieurs méthodes de déploiement.
- 🧩 Extension de Plugin, Communauté Active : Système de plugin de haute stabilité, haute sécurité de niveau production; Prend en charge les mécanismes de plugin pilotés par événements, l'extension de composants, etc. ; Intégration du protocole [MCP](https://modelcontextprotocol.io/) d'Anthropic ; Dispose actuellement de centaines de plugins.
- 😻 Interface Web : Prend en charge la gestion des instances LangBot via le navigateur. Pas besoin d'écrire manuellement les fichiers de configuration.
- 📊 Fonctionnalités de Niveau Production : Prend en charge plusieurs configurations de pipeline, différents bots pour différents scénarios. Dispose de capacités complètes de surveillance et de gestion des exceptions.
Pour des spécifications plus détaillées, veuillez consulter la [documentation](https://docs.langbot.app/en/insight/features.html).

View File

@@ -84,11 +84,12 @@ LangBotはBTPanelにリストされています。BTPanelをインストール
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG知識ベースを組み込み、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) などの LLMOps プラットフォームと深く統合。
- 💬 LLM / エージェントとのチャット: 複数のLLMをサポートし、グループチャットとプライベートチャットに対応。マルチラウンドの会話、ツールの呼び出し、マルチモーダル、ストリーミング出力機能をサポート、RAG知識ベースを組み込み、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)などの LLMOps プラットフォームと深く統合。
- 🤖 多プラットフォーム対応: 現在、QQ、QQ チャンネル、WeChat、個人 WeChat、Lark、DingTalk、Discord、Telegram、KOOK、Slack、LINE など、複数のプラットフォームをサポートしています。
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。
- 🛠️ 高い安定性、豊富な機能: ネイティブのアクセス制御、レート制限、敏感な単語のフィルタリングなどのメカニズムをサポート。使いやすく、複数のデプロイ方法をサポート。
- 🧩 プラグイン拡張、活発なコミュニティ: 高い安定性、高いセキュリティの生産レベルのプラグインシステム;イベント駆動、コンポーネント拡張などのプラグインメカニズムをサポート。適配 Anthropic [MCP プロトコル](https://modelcontextprotocol.io/);豊富なエコシステム、現在数百のプラグインが存在。
- 😻 Web UI: ブラウザを通じてLangBotインスタンスを管理することをサポート。
- 📊 生産レベルの機能: 複数のパイプライン設定をサポートし、異なるボットを異なる用途に使用できます。包括的な監視と例外処理機能を備えています。
詳細な仕様については、[ドキュメント](https://docs.langbot.app/en/insight/features.html)を参照してください。

View File

@@ -84,11 +84,12 @@ LangBot은 BTPanel에 등록되어 있습니다. BTPanel을 설치한 경우 [
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 LLM / Agent와 채팅: 여러 LLM을 지원하며 그룹 채팅 및 개인 채팅에 적응; 멀티 라운드 대화, 도구 호출, 멀티모달, 스트리밍 출력 기능을 지원합니다. 내장된 RAG(지식 베이스) 구현 및 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 등의 LLMOps 플랫폼과 깊이 통합됩니다.
- 💬 LLM / Agent와 채팅: 여러 LLM을 지원하며 그룹 채팅 및 개인 채팅에 적응; 멀티 라운드 대화, 도구 호출, 멀티모달, 스트리밍 출력 기능을 지원합니다. 내장된 RAG(지식 베이스) 구현 및 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)등의 LLMOps 플랫폼과 깊이 통합됩니다.
- 🤖 다중 플랫폼 지원: 현재 QQ, QQ Channel, WeCom, 개인 WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE 등을 지원합니다.
- 🛠️ 높은 안정성, 풍부한 기능: 네이티브 액세스 제어, 속도 제한, 민감한 단어 필터링 등의 메커니즘; 사용하기 쉽고 여러 배포 방법을 지원합니다. 여러 파이프라인 구성을 지원하며 다양한 시나리오에 대해 다른 봇을 사용할 수 있습니다.
- 🛠️ 높은 안정성, 풍부한 기능: 네이티브 액세스 제어, 속도 제한, 민감한 단어 필터링 등의 메커니즘; 사용하기 쉽고 여러 배포 방법을 지원합니다.
- 🧩 플러그인 확장, 활발한 커뮤니티: 고안정성, 고보안 생산 수준의 플러그인 시스템; 이벤트 기반, 컴포넌트 확장 등의 플러그인 메커니즘을 지원; Anthropic [MCP 프로토콜](https://modelcontextprotocol.io/) 통합; 현재 수백 개의 플러그인이 있습니다.
- 😻 웹 UI: 브라우저를 통해 LangBot 인스턴스 관리를 지원합니다. 구성 파일을 수동으로 작성할 필요가 없습니다.
- 📊 생산 수준의 기능: 여러 파이프라인 구성을 지원하며 다양한 시나리오에 대해 다른 봇을 사용할 수 있습니다. 포괄적인 모니터링 및 예외 처리 기능을 갖추고 있습니다.
더 자세한 사양은 [문서](https://docs.langbot.app/en/insight/features.html)를 참조하세요.

View File

@@ -84,11 +84,12 @@ LangBot добавлен в BTPanel. Если у вас установлен BTP
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Чат с LLM / Agent: Поддержка нескольких LLM, адаптация к групповым и личным чатам; Поддержка многораундовых разговоров, вызовов инструментов, мультимодальных возможностей и потоковой передачи. Встроенная реализация RAG (база знаний) и глубокая интеграция с [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) 등의 LLMOps 플랫포트폼과 깊이 통합됩니다.
- 💬 Чат с LLM / Agent: Поддержка нескольких LLM, адаптация к групповым и личным чатам; Поддержка многораундовых разговоров, вызовов инструментов, мультимодальных возможностей и потоковой передачи. Встроенная реализация RAG (база знаний) и глубокая интеграция с [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) и др. LLMOps платформами.
- 🤖 Многоплатформенная поддержка: В настоящее время поддерживает QQ, QQ Channel, WeCom, личный WeChat, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE и т.д.
- 🛠️ Высокая стабильность, богатство функций: Нативный контроль доступа, ограничение скорости, фильтрация чувствительных слов и т.д.; Простота в использовании, поддержка нескольких методов развертывания. Поддержка нескольких конфигураций конвейера, разные боты для разных сценариев.
- 🛠️ Высокая стабильность, богатство функций: Нативный контроль доступа, ограничение скорости, фильтрация чувствительных слов и т.д.; Простота в использовании, поддержка нескольких методов развертывания.
- 🧩 Расширение плагинов, активное сообщество: Высокая стабильность, высокая безопасность уровня производства; Поддержка механизмов плагинов, управляемых событиями, расширения компонентов и т.д.; Интеграция протокола [MCP](https://modelcontextprotocol.io/) от Anthropic; В настоящее время сотни плагинов.
- 😻 Веб-интерфейс: Поддержка управления экземплярами LangBot через браузер. Нет необходимости вручную писать конфигурационные файлы.
- 📊 Функции уровня производства: Поддержка нескольких конфигураций конвейера, разные боты для разных сценариев. Имеет комплексные возможности мониторинга и обработки исключений.
Для более подробных спецификаций обратитесь к [документации](https://docs.langbot.app/en/insight/features.html).

View File

@@ -84,11 +84,12 @@ docker compose up -d
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 大模型對話、Agent支援多種大模型適配群聊和私聊具有多輪對話、工具調用、多模態、流式輸出能力自帶 RAG知識庫實現並深度適配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io) 等 LLMOps 平台。
- 💬 大模型對話、Agent支援多種大模型適配群聊和私聊具有多輪對話、工具調用、多模態、流式輸出能力自帶 RAG知識庫實現並深度適配 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)等 LLMOps 平台。
- 🤖 多平台支援:目前支援 QQ、QQ頻道、企業微信、個人微信、飛書、Discord、Telegram、KOOK、Slack、LINE 等平台。
- 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。支援多流水線配置,不同機器人用於不同應用場景。
- 🛠️ 高穩定性、功能完備:原生支援訪問控制、限速、敏感詞過濾等機制;配置簡單,支援多種部署方式。
- 🧩 外掛擴展、活躍社群:高穩定性、高安全性的生產級外掛系統;支援事件驅動、組件擴展等外掛機制;適配 Anthropic [MCP 協議](https://modelcontextprotocol.io/);目前已有數百個外掛。
- 😻 Web 管理面板:支援通過瀏覽器管理 LangBot 實例,不再需要手動編寫配置文件
- 😻 Web 管理面板:提供先進的 WebUI 管理面板,用最直觀的方式配置、管理、監控機器人
- 📊 生產級特性:支援多流水線配置,不同機器人用於不同應用場景。具有全面的監控和異常處理能力。
詳細規格特性請訪問[文件](https://docs.langbot.app/zh/insight/features.html)。

View File

@@ -84,11 +84,12 @@ Nhấp vào các nút Star và Watch ở góc trên bên phải của kho lưu t
<img width="500" src="https://docs.langbot.app/ui/bot-page-en-rounded.png" />
- 💬 Chat với LLM / Agent: Hỗ trợ nhiều LLM, thích ứng với chat nhóm và chat riêng tư; Hỗ trợ các cuộc trò chuyện nhiều vòng, gọi công cụ, khả năng đa phương thức và đầu ra streaming. Triển khai RAG (cơ sở kiến thức) tích hợp sẵn và tích hợp sâu với [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io) v.v. LLMOps platforms.
- 💬 Chat với LLM / Agent: Hỗ trợ nhiều LLM, thích ứng với chat nhóm và chat riêng tư; Hỗ trợ các cuộc trò chuyện nhiều vòng, gọi công cụ, khả năng đa phương thức và đầu ra streaming. Triển khai RAG (cơ sở kiến thức) tích hợp sẵn và tích hợp sâu với [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) v.v. LLMOps platforms.
- 🤖 Hỗ trợ Đa nền tảng: Hiện hỗ trợ QQ, QQ Channel, WeCom, WeChat cá nhân, Lark, DingTalk, Discord, Telegram, KOOK, Slack, LINE, v.v.
- 🛠️ Độ ổn định Cao, Tính năng Phong phú: Kiểm soát truy cập gốc, giới hạn tốc độ, lọc từ nhạy cảm, v.v.; Dễ sử dụng, hỗ trợ nhiều phương pháp triển khai. Hỗ trợ nhiều cấu hình pipeline, các bot khác nhau cho các kịch bản khác nhau.
- 🛠️ Độ ổn định Cao, Tính năng Phong phú: Kiểm soát truy cập gốc, giới hạn tốc độ, lọc từ nhạy cảm, v.v.; Dễ sử dụng, hỗ trợ nhiều phương pháp triển khai.
- 🧩 Mở rộng Plugin, Cộng đồng Hoạt động: Hỗ trợ các cơ chế plugin hướng sự kiện, mở rộng thành phần, v.v.; Tích hợp giao thức [MCP](https://modelcontextprotocol.io/) của Anthropic; Hiện có hàng trăng plugin.
- 😻 Giao diện Web: Hỗ trợ quản lý các phiên bản LangBot thông qua trình duyệt. Không cần viết tệp cấu hình thủ công.
- 📊 Tính năng Cấp sản xuất: Hỗ trợ nhiều cấu hình pipeline, các bot khác nhau cho các kịch bản khác nhau. Có khả năng giám sát toàn diện và xử lý ngoại lệ.
Để biết thêm thông số kỹ thuật chi tiết, vui lòng tham khảo [tài liệu](https://docs.langbot.app/en/insight/features.html).

View File

@@ -1,7 +1,7 @@
[project]
name = "langbot"
version = "4.7.2"
description = "Production-grade platform for building IM bots"
version = "4.8.3"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
requires-python = ">=3.11,<4.0"
@@ -17,7 +17,7 @@ dependencies = [
"certifi>=2025.4.26",
"colorlog~=6.6.0",
"cryptography>=44.0.3",
"dashscope>=1.23.2",
"dashscope>=1.25.10",
"dingtalk-stream>=0.24.0",
"discord-py>=2.5.2",
"pynacl>=1.5.0", # Required for Discord voice support
@@ -63,8 +63,8 @@ dependencies = [
"langchain-text-splitters>=0.0.1",
"chromadb>=0.4.24",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb>=0.1.0",
"langbot-plugin==0.2.4",
"pyseekdb==1.0.0b7",
"langbot-plugin==0.2.5",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building IM bots"""
"""LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.7.2'
__version__ = '4.8.3'

View File

@@ -347,10 +347,15 @@ class DingTalkClient:
raise Exception(f'failed to send proactive massage to group: {traceback.format_exc()}')
async def create_and_card(
self, temp_card_id: str, incoming_message: dingtalk_stream.ChatbotMessage, quote_origin: bool = False
self,
temp_card_id: str,
incoming_message: dingtalk_stream.ChatbotMessage,
quote_origin: bool = False,
card_auto_layout: bool = False,
):
content_key = 'content'
card_data = {content_key: ''}
card_data = {}
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
card_data['content'] = ''
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
# print(card_instance)

View File

@@ -0,0 +1,325 @@
from __future__ import annotations
import datetime
import quart
from .. import group
def parse_iso_datetime(datetime_str: str | None) -> datetime.datetime | None:
"""Parse ISO 8601 datetime string, handling 'Z' suffix for UTC timezone"""
if not datetime_str:
return None
# Replace 'Z' with '+00:00' for Python 3.10 compatibility
if datetime_str.endswith('Z'):
datetime_str = datetime_str[:-1] + '+00:00'
dt = datetime.datetime.fromisoformat(datetime_str)
# Convert to UTC and remove timezone info to match database storage (which stores UTC as naive datetime)
if dt.tzinfo is not None:
# Convert to UTC and remove timezone info
dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)
return dt
@group.group_class('monitoring', '/api/v1/monitoring')
class MonitoringRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('/overview', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_overview() -> str:
"""Get overview metrics"""
# 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)
metrics = await self.ap.monitoring_service.get_overview_metrics(
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=metrics)
@self.route('/messages', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_messages() -> str:
"""Get message logs"""
# 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')
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)
messages, total = await self.ap.monitoring_service.get_messages(
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,
offset=offset,
)
return self.success(
data={
'messages': messages,
'total': total,
'limit': limit,
'offset': offset,
}
)
@self.route('/llm-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_llm_calls() -> str:
"""Get LLM call records"""
# 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')
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)
llm_calls, total = await self.ap.monitoring_service.get_llm_calls(
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,
offset=offset,
)
return self.success(
data={
'llm_calls': llm_calls,
'total': total,
'limit': limit,
'offset': offset,
}
)
@self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_embedding_calls() -> str:
"""Get embedding call records"""
# Parse query parameters
start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime')
knowledge_base_id = quart.request.args.get('knowledgeBaseId')
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)
embedding_calls, total = await self.ap.monitoring_service.get_embedding_calls(
start_time=start_time,
end_time=end_time,
knowledge_base_id=knowledge_base_id if knowledge_base_id else None,
limit=limit,
offset=offset,
)
return self.success(
data={
'embedding_calls': embedding_calls,
'total': total,
'limit': limit,
'offset': offset,
}
)
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_sessions() -> str:
"""Get session information"""
# 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')
is_active_str = quart.request.args.get('isActive')
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 is_active
is_active = None
if is_active_str:
is_active = is_active_str.lower() == 'true'
sessions, total = await self.ap.monitoring_service.get_sessions(
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,
is_active=is_active,
limit=limit,
offset=offset,
)
return self.success(
data={
'sessions': sessions,
'total': total,
'limit': limit,
'offset': offset,
}
)
@self.route('/errors', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_errors() -> str:
"""Get error logs"""
# 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')
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)
errors, total = await self.ap.monitoring_service.get_errors(
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,
offset=offset,
)
return self.success(
data={
'errors': errors,
'total': total,
'limit': limit,
'offset': offset,
}
)
@self.route('/data', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_all_data() -> str:
"""Get all monitoring data in a single request"""
# 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')
limit = int(quart.request.args.get('limit', 50))
# Parse datetime
start_time = parse_iso_datetime(start_time_str)
end_time = parse_iso_datetime(end_time_str)
# Get overview metrics
overview = await self.ap.monitoring_service.get_overview_metrics(
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,
)
# Get messages
messages, messages_total = await self.ap.monitoring_service.get_messages(
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,
offset=0,
)
# Get LLM calls
llm_calls, llm_calls_total = await self.ap.monitoring_service.get_llm_calls(
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,
offset=0,
)
# Get sessions
sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
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,
is_active=None,
limit=limit,
offset=0,
)
# Get errors
errors, errors_total = await self.ap.monitoring_service.get_errors(
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,
offset=0,
)
# Get embedding calls
embedding_calls, embedding_calls_total = await self.ap.monitoring_service.get_embedding_calls(
start_time=start_time,
end_time=end_time,
limit=limit,
offset=0,
)
return self.success(
data={
'overview': overview,
'messages': messages,
'llmCalls': llm_calls,
'embeddingCalls': embedding_calls,
'sessions': sessions,
'errors': errors,
'totalCount': {
'messages': messages_total,
'llmCalls': llm_calls_total,
'embeddingCalls': embedding_calls_total,
'sessions': sessions_total,
'errors': errors_total,
},
}
)
@self.route('/sessions/<session_id>/analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_session_analysis(session_id: str) -> str:
"""Get detailed analysis for a specific session"""
analysis = await self.ap.monitoring_service.get_session_analysis(session_id)
# Always return success with the analysis data
# The frontend will handle the 'found: false' case
return self.success(data=analysis)
@self.route('/messages/<message_id>/details', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def get_message_details(message_id: str) -> str:
"""Get detailed information for a specific message"""
details = await self.ap.monitoring_service.get_message_details(message_id)
if not details.get('found'):
return self.error(message=f'Message {message_id} not found', code=404)
return self.success(data=details)

View File

@@ -64,7 +64,9 @@ class LLMModelsService:
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.LLMModel, m) for m in models]
async def create_llm_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
async def create_llm_model(
self, model_data: dict, preserve_uuid: bool = False, auto_set_to_default_pipeline: bool = True
) -> str:
"""Create a new LLM model"""
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
@@ -95,18 +97,19 @@ class LLMModelsService:
)
self.ap.model_mgr.llm_models.append(runtime_llm_model)
# set the default pipeline model to this model
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.is_default == True
if auto_set_to_default_pipeline:
# set the default pipeline model to this model
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.is_default == True
)
)
)
pipeline = result.first()
if pipeline is not None and pipeline.config['ai']['local-agent']['model'] == '':
pipeline_config = pipeline.config
pipeline_config['ai']['local-agent']['model'] = model_data['uuid']
pipeline_data = {'config': pipeline_config}
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
pipeline = result.first()
if pipeline is not None and pipeline.config['ai']['local-agent']['model'] == '':
pipeline_config = pipeline.config
pipeline_config['ai']['local-agent']['model'] = model_data['uuid']
pipeline_data = {'config': pipeline_config}
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
return model_data['uuid']
@@ -192,7 +195,7 @@ class LLMModelsService:
runtime_llm_model = await self.ap.model_mgr.init_temporary_runtime_llm_model(model_data)
extra_args = model_data.get('extra_args', {})
await runtime_llm_model.provider.requester.invoke_llm(
await runtime_llm_model.provider.invoke_llm(
query=None,
model=runtime_llm_model,
messages=[provider_message.Message(role='user', content='Hello, world! Please just reply a "Hello".')],
@@ -354,7 +357,7 @@ class EmbeddingModelsService:
else:
runtime_embedding_model = await self.ap.model_mgr.init_temporary_runtime_embedding_model(model_data)
await runtime_embedding_model.provider.requester.invoke_embedding(
await runtime_embedding_model.provider.invoke_embedding(
model=runtime_embedding_model,
input_text=['Hello, world!'],
extra_args={},

View File

@@ -0,0 +1,796 @@
from __future__ import annotations
import uuid
import datetime
import sqlalchemy
from ....core import app
from ....entity.persistence import monitoring as persistence_monitoring
class MonitoringService:
"""Monitoring service"""
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
# ========== Recording Methods ==========
async def record_message(
self,
bot_id: str,
bot_name: str,
pipeline_id: str,
pipeline_name: str,
message_content: str,
session_id: str,
status: str = 'success',
level: str = 'info',
platform: str | None = None,
user_id: str | None = None,
runner_name: str | None = None,
variables: str | None = None,
) -> str:
"""Record a message"""
message_id = str(uuid.uuid4())
message_data = {
'id': message_id,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'message_content': message_content,
'session_id': session_id,
'status': status,
'level': level,
'platform': platform,
'user_id': user_id,
'runner_name': runner_name,
'variables': variables,
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringMessage).values(message_data)
)
return message_id
async def record_llm_call(
self,
bot_id: str,
bot_name: str,
pipeline_id: str,
pipeline_name: str,
session_id: str,
model_name: str,
input_tokens: int,
output_tokens: int,
duration: int,
status: str = 'success',
cost: float | None = None,
error_message: str | None = None,
message_id: str | None = None,
) -> str:
"""Record an LLM call"""
call_id = str(uuid.uuid4())
call_data = {
'id': call_id,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'model_name': model_name,
'input_tokens': input_tokens,
'output_tokens': output_tokens,
'total_tokens': input_tokens + output_tokens,
'duration': duration,
'cost': cost,
'status': status,
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'session_id': session_id,
'error_message': error_message,
'message_id': message_id,
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringLLMCall).values(call_data)
)
return call_id
async def record_embedding_call(
self,
model_name: str,
prompt_tokens: int,
total_tokens: int,
duration: int,
input_count: int,
status: str = 'success',
error_message: str | None = None,
knowledge_base_id: str | None = None,
query_text: str | None = None,
session_id: str | None = None,
message_id: str | None = None,
call_type: str | None = None,
) -> str:
"""Record an embedding call"""
call_id = str(uuid.uuid4())
call_data = {
'id': call_id,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'model_name': model_name,
'prompt_tokens': prompt_tokens,
'total_tokens': total_tokens,
'duration': duration,
'input_count': input_count,
'status': status,
'error_message': error_message,
'knowledge_base_id': knowledge_base_id,
'query_text': query_text,
'session_id': session_id,
'message_id': message_id,
'call_type': call_type,
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringEmbeddingCall).values(call_data)
)
return call_id
async def record_session_start(
self,
session_id: str,
bot_id: str,
bot_name: str,
pipeline_id: str,
pipeline_name: str,
platform: str | None = None,
user_id: str | None = None,
) -> None:
"""Record a new session"""
session_data = {
'session_id': session_id,
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'message_count': 0,
'start_time': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'is_active': True,
'platform': platform,
'user_id': user_id,
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringSession).values(session_data)
)
async def update_session_activity(
self,
session_id: str,
pipeline_id: str | None = None,
pipeline_name: str | None = None,
) -> bool:
"""Update session last activity time and increment message count.
Also updates pipeline info if the bot's pipeline has changed.
Returns:
True if session was found and updated, False if session doesn't exist.
"""
update_values = {
'last_activity': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'message_count': persistence_monitoring.MonitoringSession.message_count + 1,
}
# Update pipeline info if provided (handles pipeline switch)
if pipeline_id is not None:
update_values['pipeline_id'] = pipeline_id
if pipeline_name is not None:
update_values['pipeline_name'] = pipeline_name
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_monitoring.MonitoringSession)
.where(persistence_monitoring.MonitoringSession.session_id == session_id)
.values(update_values)
)
# Check if any rows were updated
return result.rowcount > 0
async def record_error(
self,
bot_id: str,
bot_name: str,
pipeline_id: str,
pipeline_name: str,
error_type: str,
error_message: str,
session_id: str | None = None,
stack_trace: str | None = None,
message_id: str | None = None,
) -> str:
"""Record an error"""
error_id = str(uuid.uuid4())
error_data = {
'id': error_id,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'error_type': error_type,
'error_message': error_message,
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'session_id': session_id,
'stack_trace': stack_trace,
'message_id': message_id,
}
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringError).values(error_data)
)
return error_id
async def update_message_status(
self,
message_id: str,
status: str,
level: str | None = None,
variables: str | None = None,
) -> None:
"""Update message status and optionally variables"""
update_values = {'status': status}
if level is not None:
update_values['level'] = level
if variables is not None:
update_values['variables'] = variables
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_monitoring.MonitoringMessage)
.where(persistence_monitoring.MonitoringMessage.id == message_id)
.values(update_values)
)
# ========== Query Methods ==========
async def get_overview_metrics(
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 overview metrics"""
# Build base query conditions
message_conditions = []
llm_conditions = []
embedding_conditions = []
session_conditions = []
if bot_ids:
message_conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
session_conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
if pipeline_ids:
message_conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids))
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids))
session_conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
if start_time:
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
session_conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
if end_time:
message_conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
llm_conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
embedding_conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
session_conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
# Total messages
message_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id))
if message_conditions:
message_query = message_query.where(sqlalchemy.and_(*message_conditions))
total_messages_result = await self.ap.persistence_mgr.execute_async(message_query)
total_messages = total_messages_result.scalar() or 0
# Total LLM calls
llm_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.id))
if llm_conditions:
llm_query = llm_query.where(sqlalchemy.and_(*llm_conditions))
llm_calls_result = await self.ap.persistence_mgr.execute_async(llm_query)
llm_calls = llm_calls_result.scalar() or 0
# Total Embedding calls
embedding_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringEmbeddingCall.id))
if embedding_conditions:
embedding_query = embedding_query.where(sqlalchemy.and_(*embedding_conditions))
embedding_calls_result = await self.ap.persistence_mgr.execute_async(embedding_query)
embedding_calls = embedding_calls_result.scalar() or 0
# Total model calls (LLM + Embedding)
model_calls = llm_calls + embedding_calls
# Success rate (based on messages)
success_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.id)).where(
persistence_monitoring.MonitoringMessage.status == 'success'
)
if message_conditions:
success_query = success_query.where(sqlalchemy.and_(*message_conditions))
success_result = await self.ap.persistence_mgr.execute_async(success_query)
success_count = success_result.scalar() or 0
success_rate = (success_count / total_messages * 100) if total_messages > 0 else 100
# Active sessions
active_session_query = sqlalchemy.select(
sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_id)
).where(persistence_monitoring.MonitoringSession.is_active == True)
if session_conditions:
active_session_query = active_session_query.where(sqlalchemy.and_(*session_conditions))
active_sessions_result = await self.ap.persistence_mgr.execute_async(active_session_query)
active_sessions = active_sessions_result.scalar() or 0
return {
'total_messages': total_messages,
'llm_calls': llm_calls,
'embedding_calls': embedding_calls,
'model_calls': model_calls,
'success_rate': round(success_rate, 2),
'active_sessions': active_sessions,
}
async def get_messages(
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 = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get messages with filters"""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringMessage.timestamp <= end_time)
# Get total count
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringMessage.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 messages
query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).order_by(
persistence_monitoring.MonitoringMessage.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)
messages_rows = result.all()
serialized = []
for row in messages_rows:
# Extract model instance from Row (SQLAlchemy returns Row objects)
msg = row[0] if isinstance(row, tuple) else row
serialized_msg = self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, msg)
serialized.append(serialized_msg)
return (serialized, total)
async def get_llm_calls(
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 = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get LLM calls with filters"""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringLLMCall.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringLLMCall.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringLLMCall.timestamp <= end_time)
# Get total count
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringLLMCall.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 LLM calls
query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).order_by(
persistence_monitoring.MonitoringLLMCall.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)
llm_calls_rows = result.all()
return (
[
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringLLMCall, row[0] if isinstance(row, tuple) else row
)
for row in llm_calls_rows
],
total,
)
async def get_embedding_calls(
self,
start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None,
knowledge_base_id: str | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get embedding calls with filters"""
conditions = []
if start_time:
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.timestamp <= end_time)
if knowledge_base_id:
conditions.append(persistence_monitoring.MonitoringEmbeddingCall.knowledge_base_id == knowledge_base_id)
# Get total count
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringEmbeddingCall.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 embedding calls
query = sqlalchemy.select(persistence_monitoring.MonitoringEmbeddingCall).order_by(
persistence_monitoring.MonitoringEmbeddingCall.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)
embedding_calls_rows = result.all()
return (
[
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringEmbeddingCall, row[0] if isinstance(row, tuple) else row
)
for row in embedding_calls_rows
],
total,
)
async def get_sessions(
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,
is_active: bool | None = None,
limit: int = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get sessions with filters"""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringSession.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringSession.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringSession.start_time <= end_time)
if is_active is not None:
conditions.append(persistence_monitoring.MonitoringSession.is_active == is_active)
# Get total count
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringSession.session_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 sessions
query = sqlalchemy.select(persistence_monitoring.MonitoringSession).order_by(
persistence_monitoring.MonitoringSession.last_activity.desc()
)
if conditions:
query = query.where(sqlalchemy.and_(*conditions))
query = query.limit(limit).offset(offset)
result = await self.ap.persistence_mgr.execute_async(query)
sessions_rows = result.all()
return (
[
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringSession, row[0] if isinstance(row, tuple) else row
)
for row in sessions_rows
],
total,
)
async def get_errors(
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 = 100,
offset: int = 0,
) -> tuple[list[dict], int]:
"""Get errors with filters"""
conditions = []
if bot_ids:
conditions.append(persistence_monitoring.MonitoringError.bot_id.in_(bot_ids))
if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringError.pipeline_id.in_(pipeline_ids))
if start_time:
conditions.append(persistence_monitoring.MonitoringError.timestamp >= start_time)
if end_time:
conditions.append(persistence_monitoring.MonitoringError.timestamp <= end_time)
# Get total count
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringError.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 errors
query = sqlalchemy.select(persistence_monitoring.MonitoringError).order_by(
persistence_monitoring.MonitoringError.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)
errors_rows = result.all()
return (
[
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row
)
for row in errors_rows
],
total,
)
async def get_session_analysis(
self,
session_id: str,
) -> dict:
"""Get detailed analysis for a specific session"""
# Get session info
session_query = sqlalchemy.select(persistence_monitoring.MonitoringSession).where(
persistence_monitoring.MonitoringSession.session_id == session_id
)
session_result = await self.ap.persistence_mgr.execute_async(session_query)
session_row = session_result.first()
if not session_row:
return {
'session_id': session_id,
'found': False,
}
session = session_row[0] if isinstance(session_row, tuple) else session_row
# Get messages for this session
messages_query = (
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
.where(persistence_monitoring.MonitoringMessage.session_id == session_id)
.order_by(persistence_monitoring.MonitoringMessage.timestamp.asc())
)
messages_result = await self.ap.persistence_mgr.execute_async(messages_query)
messages_rows = messages_result.all()
# Count messages by status
success_messages = 0
error_messages = 0
pending_messages = 0
for row in messages_rows:
msg = row[0] if isinstance(row, tuple) else row
if msg.status == 'success':
success_messages += 1
elif msg.status == 'error':
error_messages += 1
elif msg.status == 'pending':
pending_messages += 1
# Get LLM calls for this session
llm_query = sqlalchemy.select(persistence_monitoring.MonitoringLLMCall).where(
persistence_monitoring.MonitoringLLMCall.session_id == session_id
)
llm_result = await self.ap.persistence_mgr.execute_async(llm_query)
llm_rows = llm_result.all()
# Calculate LLM statistics
total_llm_calls = len(llm_rows)
total_input_tokens = 0
total_output_tokens = 0
total_tokens = 0
total_duration = 0
success_llm_calls = 0
error_llm_calls = 0
for row in llm_rows:
llm_call = row[0] if isinstance(row, tuple) else row
total_input_tokens += llm_call.input_tokens
total_output_tokens += llm_call.output_tokens
total_tokens += llm_call.total_tokens
total_duration += llm_call.duration
if llm_call.status == 'success':
success_llm_calls += 1
else:
error_llm_calls += 1
# Get errors for this session
error_query = (
sqlalchemy.select(persistence_monitoring.MonitoringError)
.where(persistence_monitoring.MonitoringError.session_id == session_id)
.order_by(persistence_monitoring.MonitoringError.timestamp.desc())
)
error_result = await self.ap.persistence_mgr.execute_async(error_query)
error_rows = error_result.all()
errors = [
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row
)
for row in error_rows
]
# Calculate session duration
if messages_rows:
first_msg = messages_rows[0][0] if isinstance(messages_rows[0], tuple) else messages_rows[0]
last_msg = messages_rows[-1][0] if isinstance(messages_rows[-1], tuple) else messages_rows[-1]
session_duration_seconds = int((last_msg.timestamp - first_msg.timestamp).total_seconds())
else:
session_duration_seconds = 0
return {
'session_id': session_id,
'found': True,
'session': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringSession, session),
'message_stats': {
'total': len(messages_rows),
'success': success_messages,
'error': error_messages,
'pending': pending_messages,
},
'llm_stats': {
'total_calls': total_llm_calls,
'success_calls': success_llm_calls,
'error_calls': error_llm_calls,
'total_input_tokens': total_input_tokens,
'total_output_tokens': total_output_tokens,
'total_tokens': total_tokens,
'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0,
},
'errors': errors,
'session_duration_seconds': session_duration_seconds,
}
async def get_message_details(
self,
message_id: str,
) -> dict:
"""Get detailed information for a specific message including associated LLM calls and errors"""
# Get message info
message_query = sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
persistence_monitoring.MonitoringMessage.id == message_id
)
message_result = await self.ap.persistence_mgr.execute_async(message_query)
message_row = message_result.first()
if not message_row:
return {
'message_id': message_id,
'found': False,
}
message = message_row[0] if isinstance(message_row, tuple) else message_row
# Get LLM calls for this message
llm_query = (
sqlalchemy.select(persistence_monitoring.MonitoringLLMCall)
.where(persistence_monitoring.MonitoringLLMCall.message_id == message_id)
.order_by(persistence_monitoring.MonitoringLLMCall.timestamp.asc())
)
llm_result = await self.ap.persistence_mgr.execute_async(llm_query)
llm_rows = llm_result.all()
llm_calls = [
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringLLMCall, row[0] if isinstance(row, tuple) else row
)
for row in llm_rows
]
# Calculate LLM statistics
total_input_tokens = sum(call.input_tokens for call in llm_rows)
total_output_tokens = sum(call.output_tokens for call in llm_rows)
total_tokens = sum(call.total_tokens for call in llm_rows)
total_duration = sum(call.duration for call in llm_rows)
# Get errors for this message
error_query = (
sqlalchemy.select(persistence_monitoring.MonitoringError)
.where(persistence_monitoring.MonitoringError.message_id == message_id)
.order_by(persistence_monitoring.MonitoringError.timestamp.asc())
)
error_result = await self.ap.persistence_mgr.execute_async(error_query)
error_rows = error_result.all()
errors = [
self.ap.persistence_mgr.serialize_model(
persistence_monitoring.MonitoringError, row[0] if isinstance(row, tuple) else row
)
for row in error_rows
]
return {
'message_id': message_id,
'found': True,
'message': self.ap.persistence_mgr.serialize_model(persistence_monitoring.MonitoringMessage, message),
'llm_calls': llm_calls,
'llm_stats': {
'total_calls': len(llm_rows),
'total_input_tokens': total_input_tokens,
'total_output_tokens': total_output_tokens,
'total_tokens': total_tokens,
'total_duration_ms': total_duration,
'average_duration_ms': int(total_duration / len(llm_rows)) if len(llm_rows) > 0 else 0,
},
'errors': errors,
}

View File

@@ -295,4 +295,7 @@ class UserService:
)
)
# Update Space model provider API keys
await self.ap.provider_service.update_space_model_provider_api_keys(api_key)
return await self.get_user_by_email(space_email)

View File

@@ -29,6 +29,7 @@ from ..api.http.service import mcp as mcp_service
from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service
from ..api.http.service import external_kb as external_kb_service
from ..api.http.service import monitoring as monitoring_service
from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr
from ..utils import logcache
@@ -143,6 +144,8 @@ class Application:
telemetry: telemetry_module.TelemetryManager = None
monitoring_service: monitoring_service.MonitoringService = None
def __init__(self):
pass

View File

@@ -0,0 +1,17 @@
from __future__ import annotations
from .. import migration
@migration.migration_class('dingtalk_card_auto_layout', 41)
class DingTalkCardAutoLayoutMigration(migration.Migration):
"""迁移"""
async def need_migrate(self) -> bool:
"""判断当前环境是否需要运行此迁移"""
return True
async def run(self):
"""执行迁移"""
self.ap.platform_cfg.data['platform-adapters']['app']['dingtalk']['card_auto_layout'] = False
await self.ap.platform_cfg.dump_config()

View File

@@ -26,6 +26,7 @@ from ...api.http.service import mcp as mcp_service
from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service
from ...api.http.service import external_kb as external_kb_service
from ...api.http.service import monitoring as monitoring_service
from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
from ...utils import logcache
@@ -149,6 +150,9 @@ class BuildAppStage(stage.BootingStage):
await http_ctrl.initialize()
ap.http_ctrl = http_ctrl
monitoring_service_inst = monitoring_service.MonitoringService(ap)
ap.monitoring_service = monitoring_service_inst
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
await asyncio.sleep(3)
await plugin_connector_inst.initialize()

View File

@@ -0,0 +1,105 @@
import sqlalchemy
from .base import Base
class MonitoringMessage(Base):
"""Monitoring message records"""
__tablename__ = 'monitoring_messages'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
message_content = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error, pending
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
class MonitoringLLMCall(Base):
"""LLM call records"""
__tablename__ = 'monitoring_llm_calls'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
input_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
output_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
total_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds
cost = sqlalchemy.Column(sqlalchemy.Float, nullable=True)
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
class MonitoringSession(Base):
"""Session tracking records"""
__tablename__ = 'monitoring_sessions'
session_id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
message_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
start_time = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
last_activity = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
class MonitoringError(Base):
"""Error log records"""
__tablename__ = 'monitoring_errors'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
error_type = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=False)
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
stack_trace = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
class MonitoringEmbeddingCall(Base):
"""Embedding call records"""
__tablename__ = 'monitoring_embedding_calls'
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
model_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
prompt_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
total_tokens = sqlalchemy.Column(sqlalchemy.Integer, nullable=False)
duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds
input_count = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # Number of input texts
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
# Optional context fields
knowledge_base_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
query_text = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # For retrieval calls
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

View File

@@ -11,6 +11,7 @@ class LegacyPipeline(Base):
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
description = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='⚙️')
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,

View File

@@ -7,6 +7,7 @@ class KnowledgeBase(Base):
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String, index=True)
description = sqlalchemy.Column(sqlalchemy.Text)
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='📚')
created_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(sqlalchemy.DateTime, default=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now())
embedding_model_uuid = sqlalchemy.Column(sqlalchemy.String, default='')
@@ -35,6 +36,7 @@ class ExternalKnowledgeBase(Base):
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String, index=True)
description = sqlalchemy.Column(sqlalchemy.Text)
emoji = sqlalchemy.Column(sqlalchemy.String(10), nullable=True, default='🔗')
plugin_author = sqlalchemy.Column(sqlalchemy.String, nullable=False)
plugin_name = sqlalchemy.Column(sqlalchemy.String, nullable=False)
retriever_name = sqlalchemy.Column(sqlalchemy.String, nullable=False)

View File

@@ -0,0 +1,58 @@
import sqlalchemy
from .. import migration
@migration.migration_class(18)
class DBMigrateAddEmojiSupport(migration.DBMigration):
"""Add emoji field to knowledge_bases, external_knowledge_bases and legacy_pipelines tables"""
async def upgrade(self):
"""Upgrade"""
# Add emoji field to knowledge_bases
await self._add_emoji_to_table('knowledge_bases', '📚')
# Add emoji field to external_knowledge_bases
await self._add_emoji_to_table('external_knowledge_bases', '🔗')
# Add emoji field to legacy_pipelines
await self._add_emoji_to_table('legacy_pipelines', '⚙️')
async def _add_emoji_to_table(self, table_name: str, default_emoji: str):
"""Add emoji column to specified table if it doesn't exist"""
# Get all column names from the table
columns = []
if self.ap.persistence_mgr.db.name == 'postgresql':
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(
f"SELECT column_name FROM information_schema.columns WHERE table_name = '{table_name}';"
)
)
all_result = result.fetchall()
columns = [row[0] for row in all_result]
else:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
all_result = result.fetchall()
columns = [row[1] for row in all_result]
# Check and add emoji column
if 'emoji' not in columns:
if self.ap.persistence_mgr.db.name == 'postgresql':
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f"ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10) DEFAULT '{default_emoji}'")
)
else:
# SQLite doesn't support DEFAULT with emoji directly in ALTER TABLE
# Add column without default first
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN emoji VARCHAR(10)')
)
# Set default emoji value for existing records
await self.ap.persistence_mgr.execute_async(
sqlalchemy.text(f"UPDATE {table_name} SET emoji = '{default_emoji}' WHERE emoji IS NULL")
)
async def downgrade(self):
"""Downgrade"""
pass

View File

@@ -0,0 +1,270 @@
"""
Monitoring helper for recording events during pipeline execution.
This module provides convenient methods to record monitoring data
without cluttering the main pipeline code.
"""
from __future__ import annotations
import traceback
import typing
import time
import json
if typing.TYPE_CHECKING:
from ..core import app
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
class MonitoringHelper:
"""Helper class for monitoring operations"""
@staticmethod
async def record_query_start(
ap: app.Application,
query: pipeline_query.Query,
bot_id: str,
bot_name: str,
pipeline_id: str,
pipeline_name: str,
runner_name: str | None = None,
) -> str:
"""Record the start of query processing, returns message_id"""
try:
# Check if session exists, if not, record session start
session_id = f'{query.launcher_type}_{query.launcher_id}'
# Try to record message
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
message_content = json.dumps(query.message_chain.model_dump(), ensure_ascii=False)
else:
message_content = str(query)
# Variables will be updated in record_query_success after preproc stage sets them
# Here we just record None, the full variables will be set when query completes
message_id = await ap.monitoring_service.record_message(
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
message_content=message_content,
session_id=session_id,
status='pending',
level='info',
platform=query.launcher_type.value
if hasattr(query.launcher_type, 'value')
else str(query.launcher_type),
user_id=query.sender_id,
runner_name=runner_name,
variables=None, # Will be updated in record_query_success
)
# Update session activity or create new session if it doesn't exist
# Always pass pipeline info to handle pipeline switches
session_updated = await ap.monitoring_service.update_session_activity(
session_id,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
)
if not session_updated:
# Session doesn't exist, create it
await ap.monitoring_service.record_session_start(
session_id=session_id,
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
platform=query.launcher_type.value
if hasattr(query.launcher_type, 'value')
else str(query.launcher_type),
user_id=query.sender_id,
)
return message_id
except Exception as e:
ap.logger.error(f'Failed to record query start: {e}')
return ''
@staticmethod
async def record_query_success(
ap: app.Application,
message_id: str,
query: pipeline_query.Query | None = None,
):
"""Record successful query processing by updating message status and variables"""
try:
if message_id:
# Serialize query.variables (filtering out internal variables)
query_variables_str = None
if query and hasattr(query, 'variables') and query.variables:
filtered_vars = {k: v for k, v in query.variables.items() if not k.startswith('_')}
if filtered_vars:
try:
query_variables_str = json.dumps(filtered_vars, ensure_ascii=False, default=str)
except Exception:
pass
await ap.monitoring_service.update_message_status(
message_id=message_id,
status='success',
variables=query_variables_str,
)
except Exception as e:
ap.logger.error(f'Failed to record query success: {e}')
@staticmethod
async def record_query_error(
ap: app.Application,
query: pipeline_query.Query,
bot_id: str,
bot_name: str,
pipeline_id: str,
pipeline_name: str,
error: Exception,
runner_name: str | None = None,
) -> str:
"""Record query processing error, returns message_id"""
try:
session_id = f'{query.launcher_type}_{query.launcher_id}'
# Record error message
message_id = await ap.monitoring_service.record_message(
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
message_content=f'Error: {str(error)}',
session_id=session_id,
status='error',
level='error',
platform=query.launcher_type.value
if hasattr(query.launcher_type, 'value')
else str(query.launcher_type),
user_id=query.sender_id,
runner_name=runner_name,
)
# Record error log
await ap.monitoring_service.record_error(
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
error_type=type(error).__name__,
error_message=str(error),
session_id=session_id,
stack_trace=traceback.format_exc(),
message_id=message_id,
)
return message_id
except Exception as e:
ap.logger.error(f'Failed to record query error: {e}')
return ''
@staticmethod
async def record_llm_call(
ap: app.Application,
query: pipeline_query.Query,
bot_id: str,
bot_name: str,
pipeline_id: str,
pipeline_name: str,
model_name: str,
input_tokens: int,
output_tokens: int,
duration_ms: int,
status: str = 'success',
cost: float | None = None,
error_message: str | None = None,
message_id: str | None = None,
):
"""Record LLM call"""
try:
session_id = f'{query.launcher_type}_{query.launcher_id}'
await ap.monitoring_service.record_llm_call(
bot_id=bot_id,
bot_name=bot_name,
pipeline_id=pipeline_id,
pipeline_name=pipeline_name,
session_id=session_id,
model_name=model_name,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration=duration_ms,
status=status,
cost=cost,
error_message=error_message,
message_id=message_id,
)
except Exception as e:
ap.logger.error(f'Failed to record LLM call: {e}')
class LLMCallMonitor:
"""Context manager for monitoring LLM calls"""
def __init__(
self,
ap: app.Application,
query: pipeline_query.Query,
bot_id: str,
bot_name: str,
pipeline_id: str,
pipeline_name: str,
model_name: str,
):
self.ap = ap
self.query = query
self.bot_id = bot_id
self.bot_name = bot_name
self.pipeline_id = pipeline_id
self.pipeline_name = pipeline_name
self.model_name = model_name
self.start_time = None
self.input_tokens = 0
self.output_tokens = 0
async def __aenter__(self):
self.start_time = time.time()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
duration_ms = int((time.time() - self.start_time) * 1000)
if exc_type is not None:
# Error occurred
await MonitoringHelper.record_llm_call(
ap=self.ap,
query=self.query,
bot_id=self.bot_id,
bot_name=self.bot_name,
pipeline_id=self.pipeline_id,
pipeline_name=self.pipeline_name,
model_name=self.model_name,
input_tokens=self.input_tokens,
output_tokens=self.output_tokens,
duration_ms=duration_ms,
status='error',
error_message=str(exc_val) if exc_val else None,
)
else:
# Success
await MonitoringHelper.record_llm_call(
ap=self.ap,
query=self.query,
bot_id=self.bot_id,
bot_name=self.bot_name,
pipeline_id=self.pipeline_id,
pipeline_name=self.pipeline_name,
model_name=self.model_name,
input_tokens=self.input_tokens,
output_tokens=self.output_tokens,
duration_ms=duration_ms,
status='success',
)
return False # Don't suppress exceptions

View File

@@ -115,6 +115,25 @@ class RuntimePipeline:
# Store bound plugins and MCP servers in query for filtering
query.variables['_pipeline_bound_plugins'] = self.bound_plugins
query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers
# Record query start for monitoring
try:
# Get bot name from bot_uuid
bot_name = 'WebChat'
if query.bot_uuid:
try:
bot = await self.ap.bot_service.get_bot(query.bot_uuid, include_secret=False)
if bot:
bot_name = bot.get('name', 'Unknown')
except Exception:
pass
# Store for later use in process_query
query.variables['_monitoring_bot_name'] = bot_name
query.variables['_monitoring_pipeline_name'] = self.pipeline_entity.name
except Exception as e:
self.ap.logger.error(f'Failed to prepare monitoring data: {e}')
await self.process_query(query)
async def _check_output(self, query: pipeline_query.Query, result: pipeline_entities.StageProcessResult):
@@ -131,7 +150,7 @@ class RuntimePipeline:
query.message_event, platform_events.GroupMessage
):
result.user_notice.insert(0, platform_message.At(target=query.message_event.sender.id))
if await query.adapter.is_stream_output_supported():
if await query.adapter.is_stream_output_supported() and query.resp_messages:
await query.adapter.reply_message_chunk(
message_source=query.message_event,
bot_message=query.resp_messages[-1],
@@ -151,6 +170,37 @@ class RuntimePipeline:
self.ap.logger.info(result.console_notice)
if result.error_notice:
self.ap.logger.error(result.error_notice)
# Mark query as having error
query.variables['_monitoring_has_error'] = True
# Record error to monitoring system
try:
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
message_id = query.variables.get('_monitoring_message_id', '')
session_id = f'{query.launcher_type}_{query.launcher_id}'
# Update message status to error
if message_id:
await self.ap.monitoring_service.update_message_status(
message_id=message_id,
status='error',
level='error',
)
# Record error log
await self.ap.monitoring_service.record_error(
bot_id=query.bot_uuid or 'unknown',
bot_name=bot_name,
pipeline_id=self.pipeline_entity.uuid,
pipeline_name=pipeline_name,
error_type='PipelineError',
error_message=result.error_notice,
session_id=session_id,
stack_trace=result.debug_notice if result.debug_notice else None,
message_id=message_id,
)
except Exception as e:
self.ap.logger.error(f'Failed to record error to monitoring: {e}')
async def _execute_from_stage(
self,
@@ -221,6 +271,34 @@ class RuntimePipeline:
async def process_query(self, query: pipeline_query.Query):
"""处理请求"""
# Get monitoring metadata
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
# Get runner name from pipeline config
runner_name = None
if query.pipeline_config and 'ai' in query.pipeline_config and 'runner' in query.pipeline_config['ai']:
runner_name = query.pipeline_config['ai']['runner'].get('runner')
# Record query start and store message_id
message_id = ''
try:
from . import monitoring_helper
message_id = await monitoring_helper.MonitoringHelper.record_query_start(
ap=self.ap,
query=query,
bot_id=query.bot_uuid or 'unknown',
bot_name=bot_name,
pipeline_id=self.pipeline_entity.uuid,
pipeline_name=pipeline_name,
runner_name=runner_name,
)
# Store message_id in query variables for LLM call monitoring
query.variables['_monitoring_message_id'] = message_id
except Exception as e:
self.ap.logger.error(f'Failed to record query start: {e}')
try:
# Get bound plugins for this pipeline
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
@@ -249,10 +327,40 @@ class RuntimePipeline:
self.ap.logger.debug(f'Processing query {query.query_id}')
await self._execute_from_stage(0, query)
# Record query success only if no error occurred during processing
if not query.variables.get('_monitoring_has_error', False):
try:
await monitoring_helper.MonitoringHelper.record_query_success(
ap=self.ap,
message_id=message_id,
query=query,
)
except Exception as e:
self.ap.logger.error(f'Failed to record query success: {e}')
except Exception as e:
inst_name = query.current_stage_name if query.current_stage_name else 'unknown'
self.ap.logger.error(f'Error processing query {query.query_id} stage={inst_name} : {e}')
self.ap.logger.error(f'Traceback: {traceback.format_exc()}')
# Record query error
try:
from . import monitoring_helper
await monitoring_helper.MonitoringHelper.record_query_error(
ap=self.ap,
query=query,
bot_id=query.bot_uuid or 'unknown',
bot_name=bot_name,
pipeline_id=self.pipeline_entity.uuid,
pipeline_name=pipeline_name,
error=e,
runner_name=runner_name,
)
except Exception as me:
self.ap.logger.error(f'Failed to record query error: {me}')
finally:
self.ap.logger.debug(f'Query {query.query_id} processed')
del self.ap.query_pool.cached_queries[query.query_id]

View File

@@ -231,7 +231,10 @@ class DingTalkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
card_template_id = self.config['card_template_id']
incoming_message = event.source_platform_object.incoming_message
# message_id = incoming_message.message_id
card_instance, card_instance_id = await self.bot.create_and_card(card_template_id, incoming_message)
card_auto_layout = self.config.get('card_ auto_layout', False)
card_instance, card_instance_id = await self.bot.create_and_card(
card_template_id, incoming_message, card_auto_layout=card_auto_layout
)
self.card_instance_id_dict[message_id] = (card_instance, card_instance_id)
return True

View File

@@ -56,6 +56,13 @@ spec:
type: boolean
required: true
default: false
- name: card_auto_layout
label:
en_US: Card Auto Layout
zh_Hans: 卡片宽屏自动布局
type: boolean
required: false
default: false
- name: card_template_id
label:
en_US: card template id

View File

@@ -85,6 +85,26 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
)
)
if message.voice:
if message.caption:
message_components.extend(parse_message_text(message.caption))
file = await message.voice.get_file()
file_bytes = None
file_format = message.voice.mime_type or 'audio/ogg'
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.get(file.file_path) as response:
file_bytes = await response.read()
message_components.append(
platform_message.Voice(
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
length=message.voice.duration,
)
)
return platform_message.MessageChain(message_components)
@@ -159,7 +179,9 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
application = ApplicationBuilder().token(config['token']).build()
bot = application.bot
application.add_handler(MessageHandler(filters.TEXT | (filters.COMMAND) | filters.PHOTO, telegram_callback))
application.add_handler(
MessageHandler(filters.TEXT | (filters.COMMAND) | filters.PHOTO | filters.VOICE, telegram_callback)
)
super().__init__(
config=config,
logger=logger,

View File

@@ -324,7 +324,7 @@ class RuntimeConnectionHandler(handler.Handler):
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
funcs_obj = [resource_tool.LLMTool.model_validate(func) for func in funcs]
result = await llm_model.provider.requester.invoke_llm(
result = await llm_model.provider.invoke_llm(
query=None,
model=llm_model,
messages=messages_obj,

View File

@@ -149,6 +149,7 @@ class ModelManager:
'prefered_ranking': space_model.featured_order,
},
preserve_uuid=True,
auto_set_to_default_pipeline=False,
)
elif space_model.category == 'embedding':

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import abc
import typing
import time
from ...core import app
from ...entity.persistence import model as persistence_model
@@ -33,6 +34,219 @@ class RuntimeProvider:
self.token_mgr = token_mgr
self.requester = requester
async def invoke_llm(
self,
query: pipeline_query.Query,
model: RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.Message:
"""Bridge method for invoking LLM with monitoring"""
# Start timing for monitoring
start_time = time.time()
input_tokens = 0
output_tokens = 0
status = 'success'
error_message = None
try:
# Call the underlying requester
result = await self.requester.invoke_llm(
query=query,
model=model,
messages=messages,
funcs=funcs,
extra_args=extra_args,
remove_think=remove_think,
)
# Try to extract token usage if the requester returns it
# For requesters that return tuple (message, usage_info)
if isinstance(result, tuple):
msg, usage_info = result
if usage_info:
input_tokens = usage_info.get('input_tokens', 0)
output_tokens = usage_info.get('output_tokens', 0)
return msg
else:
return result
except Exception as e:
status = 'error'
error_message = str(e)
raise
finally:
# Record LLM call monitoring data (only if query is provided)
if query is not None:
duration_ms = int((time.time() - start_time) * 1000)
# Import monitoring helper
try:
from ...pipeline import monitoring_helper
# Get monitoring metadata from query variables
if query.variables:
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
message_id = query.variables.get('_monitoring_message_id')
else:
bot_name = 'Unknown'
pipeline_name = 'Unknown'
message_id = None
await monitoring_helper.MonitoringHelper.record_llm_call(
ap=self.requester.ap,
query=query,
bot_id=query.bot_uuid or 'unknown',
bot_name=bot_name,
pipeline_id=query.pipeline_uuid or 'unknown',
pipeline_name=pipeline_name,
model_name=model.model_entity.name,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration_ms=duration_ms,
status=status,
error_message=error_message,
message_id=message_id,
)
except Exception as monitor_err:
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM call: {monitor_err}')
async def invoke_llm_stream(
self,
query: pipeline_query.Query,
model: RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.MessageChunk:
"""Bridge method for invoking LLM stream with monitoring"""
# Start timing for monitoring
start_time = time.time()
status = 'success'
error_message = None
# Note: Stream doesn't easily provide token counts, set to 0
input_tokens = 0
output_tokens = 0
try:
# Stream the response
async for chunk in self.requester.invoke_llm_stream(
query=query,
model=model,
messages=messages,
funcs=funcs,
extra_args=extra_args,
remove_think=remove_think,
):
yield chunk
except Exception as e:
status = 'error'
error_message = str(e)
raise
finally:
# Record LLM call monitoring data (only if query is provided)
if query is not None:
duration_ms = int((time.time() - start_time) * 1000)
# Import monitoring helper
try:
from ...pipeline import monitoring_helper
# Get monitoring metadata from query variables
if query.variables:
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
message_id = query.variables.get('_monitoring_message_id')
else:
bot_name = 'Unknown'
pipeline_name = 'Unknown'
message_id = None
await monitoring_helper.MonitoringHelper.record_llm_call(
ap=self.requester.ap,
query=query,
bot_id=query.bot_uuid or 'unknown',
bot_name=bot_name,
pipeline_id=query.pipeline_uuid or 'unknown',
pipeline_name=pipeline_name,
model_name=model.model_entity.name,
input_tokens=input_tokens,
output_tokens=output_tokens,
duration_ms=duration_ms,
status=status,
error_message=error_message,
message_id=message_id,
)
except Exception as monitor_err:
self.requester.ap.logger.error(f'[Monitoring] Failed to record LLM stream call: {monitor_err}')
async def invoke_embedding(
self,
model: RuntimeEmbeddingModel,
input_text: typing.List[str],
extra_args: dict[str, typing.Any] = {},
knowledge_base_id: str | None = None,
query_text: str | None = None,
session_id: str | None = None,
message_id: str | None = None,
call_type: str | None = None,
) -> typing.List[typing.List[float]]:
"""Bridge method for invoking embedding with monitoring"""
# Start timing for monitoring
start_time = time.time()
prompt_tokens = 0
total_tokens = 0
status = 'success'
error_message = None
try:
# Call the underlying requester
result = await self.requester.invoke_embedding(
model=model,
input_text=input_text,
extra_args=extra_args,
)
# Handle both old format (list only) and new format (tuple with usage)
if isinstance(result, tuple):
embeddings, usage_info = result
if usage_info:
prompt_tokens = usage_info.get('prompt_tokens', 0)
total_tokens = usage_info.get('total_tokens', 0)
return embeddings
else:
return result
except Exception as e:
status = 'error'
error_message = str(e)
raise
finally:
# Record embedding call monitoring data
duration_ms = int((time.time() - start_time) * 1000)
try:
await self.requester.ap.monitoring_service.record_embedding_call(
model_name=model.model_entity.name,
prompt_tokens=prompt_tokens,
total_tokens=total_tokens,
duration=duration_ms,
input_count=len(input_text),
status=status,
error_message=error_message,
knowledge_base_id=knowledge_base_id,
query_text=query_text,
session_id=session_id,
message_id=message_id,
call_type=call_type,
)
except Exception as monitor_err:
self.requester.ap.logger.error(f'[Monitoring] Failed to record embedding call: {monitor_err}')
class RuntimeLLMModel:
"""运行时模型"""
@@ -141,7 +355,7 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
model: RuntimeEmbeddingModel,
input_text: typing.List[str],
extra_args: dict[str, typing.Any] = {},
) -> typing.List[typing.List[float]]:
) -> typing.Union[typing.List[typing.List[float]], tuple[typing.List[typing.List[float]], dict]]:
"""调用 Embedding API
Args:
@@ -151,5 +365,6 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
Returns:
typing.List[typing.List[float]]: 返回的 embedding 向量
或者 tuple[typing.List[typing.List[float]], dict]: 返回 (embedding 向量, usage_info)
"""
pass

View File

@@ -253,7 +253,7 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
use_funcs: list[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.Message:
) -> tuple[provider_message.Message, dict]:
self.client.api_key = use_model.provider.token_mgr.get_token()
args = {}
@@ -285,7 +285,14 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
# 处理请求结果
message = await self._make_msg(resp, remove_think)
return message
# Extract token usage from response
usage_info = {}
if hasattr(resp, 'usage') and resp.usage:
usage_info['input_tokens'] = resp.usage.prompt_tokens or 0
usage_info['output_tokens'] = resp.usage.completion_tokens or 0
usage_info['total_tokens'] = resp.usage.total_tokens or 0
return message, usage_info
async def invoke_llm(
self,
@@ -295,7 +302,8 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.Message:
) -> tuple[provider_message.Message, dict]:
"""Invoke LLM and return message with usage info"""
req_messages = [] # req_messages 仅用于类内,外部同步由 query.messages 进行
for m in messages:
msg_dict = m.dict(exclude_none=True)
@@ -308,7 +316,7 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
req_messages.append(msg_dict)
try:
msg = await self._closure(
msg, usage_info = await self._closure(
query=query,
req_messages=req_messages,
use_model=model,
@@ -316,30 +324,38 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
extra_args=extra_args,
remove_think=remove_think,
)
return msg
return msg, usage_info
except asyncio.TimeoutError:
raise errors.RequesterError('请求超时')
except openai.BadRequestError as e:
if 'context_length_exceeded' in e.message:
raise errors.RequesterError(f'上文过长,请重置会话: {e.message}')
error_message = str(e.message) if hasattr(e, 'message') else str(e)
if 'context_length_exceeded' in str(e):
raise errors.RequesterError(f'上文过长,请重置会话: {error_message}')
else:
raise errors.RequesterError(f'请求参数错误: {e.message}')
raise errors.RequesterError(f'请求参数错误: {error_message}')
except openai.AuthenticationError as e:
raise errors.RequesterError(f'无效的 api-key: {e.message}')
error_message = str(e.message) if hasattr(e, 'message') else str(e)
raise errors.RequesterError(f'无效的 api-key: {error_message}')
except openai.NotFoundError as e:
raise errors.RequesterError(f'请求路径错误: {e.message}')
error_message = str(e.message) if hasattr(e, 'message') else str(e)
raise errors.RequesterError(f'请求路径错误: {error_message}')
except openai.RateLimitError as e:
raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}')
error_message = str(e.message) if hasattr(e, 'message') else str(e)
raise errors.RequesterError(f'请求过于频繁或余额不足: {error_message}')
except openai.APIConnectionError as e:
error_message = f'连接错误: {str(e)}'
raise errors.RequesterError(error_message)
except openai.APIError as e:
raise errors.RequesterError(f'请求错误: {e.message}')
error_message = str(e.message) if hasattr(e, 'message') else str(e)
raise errors.RequesterError(f'请求错误: {error_message}')
async def invoke_embedding(
self,
model: requester.RuntimeEmbeddingModel,
input_text: list[str],
extra_args: dict[str, typing.Any] = {},
) -> list[list[float]]:
"""调用 Embedding API"""
) -> tuple[list[list[float]], dict]:
"""调用 Embedding API, returns (embeddings, usage_info)"""
self.client.api_key = model.provider.token_mgr.get_token()
args = {
@@ -355,7 +371,13 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
try:
resp = await self.client.embeddings.create(**args)
return [d.embedding for d in resp.data]
# Extract usage info
usage_info = {}
if hasattr(resp, 'usage') and resp.usage:
usage_info['prompt_tokens'] = resp.usage.prompt_tokens or 0
usage_info['total_tokens'] = resp.usage.total_tokens or 0
return [d.embedding for d in resp.data], usage_info
except asyncio.TimeoutError:
raise errors.RequesterError('请求超时')
except openai.BadRequestError as e:

View File

@@ -25,7 +25,7 @@ class DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions):
use_funcs: list[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.Message:
) -> tuple[provider_message.Message, dict]:
self.client.api_key = use_model.provider.token_mgr.get_token()
args = {}
@@ -43,7 +43,7 @@ class DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions):
# deepseek 不支持多模态把content都转换成纯文字
for m in messages:
if 'content' in m and isinstance(m['content'], list):
m['content'] = ' '.join([c['text'] for c in m['content']])
m['content'] = ' '.join([c['text'] for c in m['content'] if 'text' in c])
args['messages'] = messages
@@ -57,4 +57,11 @@ class DeepseekChatCompletions(chatcmpl.OpenAIChatCompletions):
# 处理请求结果
message = await self._make_msg(resp, remove_think)
return message
# Extract token usage from response
usage_info = {}
if hasattr(resp, 'usage') and resp.usage:
usage_info['input_tokens'] = resp.usage.prompt_tokens or 0
usage_info['output_tokens'] = resp.usage.completion_tokens or 0
usage_info['total_tokens'] = resp.usage.total_tokens or 0
return message, usage_info

View File

@@ -130,7 +130,7 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
use_funcs: list[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.Message:
) -> tuple[provider_message.Message, dict]:
self.client.api_key = use_model.provider.token_mgr.get_token()
args = {}
@@ -162,7 +162,10 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
# 处理请求结果
message = await self._make_msg(resp)
return message
# ModelScope uses streaming, usage info not available
usage_info = {}
return message, usage_info
async def _req_stream(
self,

View File

@@ -26,7 +26,7 @@ class MoonshotChatCompletions(chatcmpl.OpenAIChatCompletions):
use_funcs: list[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> provider_message.Message:
) -> tuple[provider_message.Message, dict]:
self.client.api_key = use_model.provider.token_mgr.get_token()
args = {}
@@ -57,4 +57,11 @@ class MoonshotChatCompletions(chatcmpl.OpenAIChatCompletions):
# 处理请求结果
message = await self._make_msg(resp, remove_think)
return message
# Extract token usage from response
usage_info = {}
if hasattr(resp, 'usage') and resp.usage:
usage_info['input_tokens'] = resp.usage.prompt_tokens or 0
usage_info['output_tokens'] = resp.usage.completion_tokens or 0
usage_info['total_tokens'] = resp.usage.total_tokens or 0
return message, usage_info

View File

@@ -118,6 +118,7 @@ class DashScopeAPIRunner(runner.RequestRunner):
stream=True, # 流式输出
incremental_output=True, # 增量输出,使用流式输出需要开启增量输出
session_id=query.session.using_conversation.uuid, # 会话ID用于多轮对话
enable_thinking=has_thoughts,
has_thoughts=has_thoughts,
# rag_options={ # 主要用于文件交互,暂不支持
# "session_file_ids": ["FILE_ID1"], # FILE_ID1 替换为实际的临时文件ID,逗号隔开多个
@@ -141,14 +142,14 @@ class DashScopeAPIRunner(runner.RequestRunner):
# 获取流式传输的output
stream_output = chunk.get('output', {})
stream_think = stream_output.get('thoughts', [])
if stream_think[0].get('thought'):
if stream_think and stream_think[0].get('thought'):
if not think_start:
think_start = True
pending_content += f'<think>\n{stream_think[0].get("thought")}'
else:
# 继续输出 reasoning_content
pending_content += stream_think[0].get('thought')
elif stream_think[0].get('thought') == '' and not think_end:
elif (not stream_think or stream_think[0].get('thought') == '') and not think_end:
think_end = True
pending_content += '\n</think>\n'
if stream_output.get('text') is not None:

View File

@@ -289,12 +289,16 @@ class DifyServiceAPIRunner(runner.RequestRunner):
yield msg
if chunk['event'] == 'message_file':
if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant':
base_url = self.dify_client.base_url
# 检查URL是否已经是完整的连接
if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'):
image_url = chunk['url']
else:
base_url = self.dify_client.base_url
if base_url.endswith('/v1'):
base_url = base_url[:-3]
if base_url.endswith('/v1'):
base_url = base_url[:-3]
image_url = base_url + chunk['url']
image_url = base_url + chunk['url']
yield provider_message.Message(
role='assistant',
@@ -559,12 +563,16 @@ class DifyServiceAPIRunner(runner.RequestRunner):
if chunk['event'] == 'message_file':
message_idx += 1
if chunk['type'] == 'image' and chunk['belongs_to'] == 'assistant':
base_url = self.dify_client.base_url
# 检查URL是否已经是完整的连接
if chunk['url'].startswith('http://') or chunk['url'].startswith('https://'):
image_url = chunk['url']
else:
base_url = self.dify_client.base_url
if base_url.endswith('/v1'):
base_url = base_url[:-3]
if base_url.endswith('/v1'):
base_url = base_url[:-3]
image_url = base_url + chunk['url']
image_url = base_url + chunk['url']
yield provider_message.MessageChunk(
role='assistant',

View File

@@ -130,7 +130,7 @@ class LocalAgentRunner(runner.RequestRunner):
if not is_stream:
# 非流式输出,直接请求
msg = await use_llm_model.provider.requester.invoke_llm(
msg = await use_llm_model.provider.invoke_llm(
query,
use_llm_model,
req_messages,
@@ -147,7 +147,7 @@ class LocalAgentRunner(runner.RequestRunner):
accumulated_content = '' # 从开始累积的所有内容
last_role = 'assistant'
msg_sequence = 1
async for msg in use_llm_model.provider.requester.invoke_llm_stream(
async for msg in use_llm_model.provider.invoke_llm_stream(
query,
use_llm_model,
req_messages,
@@ -265,7 +265,7 @@ class LocalAgentRunner(runner.RequestRunner):
last_role = 'assistant'
msg_sequence = first_end_sequence
async for msg in use_llm_model.provider.requester.invoke_llm_stream(
async for msg in use_llm_model.provider.invoke_llm_stream(
query,
use_llm_model,
req_messages,
@@ -321,7 +321,7 @@ class LocalAgentRunner(runner.RequestRunner):
)
else:
# 处理完所有调用,再次请求
msg = await use_llm_model.provider.requester.invoke_llm(
msg = await use_llm_model.provider.invoke_llm(
query,
use_llm_model,
req_messages,

View File

@@ -38,10 +38,12 @@ class Embedder(BaseService):
for i in range(0, len(chunks), MAX_BATCH_SIZE):
batch = chunks[i : i + MAX_BATCH_SIZE]
batch_embeddings = await embedding_model.provider.requester.invoke_embedding(
batch_embeddings = await embedding_model.provider.invoke_embedding(
model=embedding_model,
input_text=batch,
extra_args={}, # TODO: add extra args
knowledge_base_id=kb_id,
call_type='embedding',
)
embeddings_list.extend(batch_embeddings)

View File

@@ -19,10 +19,13 @@ class Retriever(base_service.BaseService):
f"Retrieving for query: '{query[:10]}' with k={k} using {embedding_model.model_entity.uuid}"
)
query_embedding: list[float] = await embedding_model.provider.requester.invoke_embedding(
query_embedding: list[float] = await embedding_model.provider.invoke_embedding(
model=embedding_model,
input_text=[query],
extra_args={}, # TODO: add extra args
knowledge_base_id=kb_id,
query_text=query,
call_type='retrieve',
)
vector_results = await self.ap.vector_db_mgr.vector_db.search(kb_id, query_embedding[0], k)

View File

@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}'
required_database_version = 17
required_database_version = 18
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False

5801
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
{
"*.{js,jsx,ts,tsx}": ["next lint --fix --file", "next lint --file"],
"*.{js,jsx,ts,tsx}": ["eslint --fix"],
"**/*": ["bash -c 'cd \"$(pwd)\" && next build"]
}

11210
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,8 @@
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"lint-staged": "lint-staged"
},
"lint-staged": {
@@ -50,9 +51,9 @@
"i18next": "^25.1.2",
"i18next-browser-languagedetector": "^8.1.0",
"input-otp": "^1.4.2",
"lodash": "^4.17.21",
"lodash": "^4.17.23",
"lucide-react": "^0.507.0",
"next": "~15.5.9",
"next": "~16.1.5",
"next-themes": "^0.4.6",
"postcss": "^8.5.3",
"qrcode": "^1.5.4",
@@ -63,6 +64,7 @@
"react-markdown": "^10.1.0",
"react-photo-view": "^1.2.7",
"react-syntax-highlighter": "^16.1.0",
"recharts": "2.15.4",
"rehype-autolink-headings": "^7.1.0",
"rehype-highlight": "^7.0.2",
"rehype-raw": "^7.0.0",

1566
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,7 @@ import {
CardDescription,
} from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import langbotIcon from '@/app/assets/langbot-logo.webp';
function SpaceOAuthCallbackContent() {
@@ -174,9 +175,7 @@ function SpaceOAuthCallbackContent() {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center space-y-4">
{status === 'loading' && (
<Loader2 className="h-12 w-12 animate-spin text-primary" />
)}
{status === 'loading' && <LoadingSpinner size="lg" text="" />}
{status === 'confirm' && (
<>
<AlertTriangle className="h-12 w-12 text-yellow-500" />
@@ -232,7 +231,7 @@ function LoadingFallback() {
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Card className="w-[400px] shadow-lg dark:shadow-white/10">
<CardContent className="flex flex-col items-center py-12">
<Loader2 className="h-12 w-12 animate-spin text-primary" />
<LoadingSpinner size="lg" text="" />
</CardContent>
</Card>
</div>

View File

@@ -6,12 +6,33 @@ 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 } from 'lucide-react';
import { Check, ChevronDown, ChevronRight } from 'lucide-react';
import { toast } from 'sonner';
export function BotLogCard({ botLog }: { botLog: BotLog }) {
const { t } = useTranslation();
const baseURL = httpClient.getBaseUrl();
const [copied, setCopied] = useState(false);
const [expanded, setExpanded] = useState(false);
// 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();
try {
document.execCommand('copy');
toast.success(t('common.copySuccess'));
} catch {
toast.error(t('common.copyFailed'));
}
document.body.removeChild(textArea);
}
function formatTime(timestamp: number) {
const now = new Date();
@@ -63,6 +84,15 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
}
}
// 截取文本的简短版本
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;
return (
<div className={`${styles.botLogCardContainer}`}>
{/* 头部标签,时间 */}
@@ -78,13 +108,24 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
{botLog.message_session_id && (
<div
className={`${styles.tag} ${styles.chatTag} relative`}
onClick={() => {
navigator.clipboard
.writeText(botLog.message_session_id)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
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);
}
}}
title={t('common.clickToCopy')}
>
@@ -125,12 +166,38 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
</div>
)}
</div>
<div className={`${styles.timestamp}`}>
{formatTime(botLog.timestamp)}
<div className="flex items-center gap-2">
{needsExpand && (
<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"
>
{expanded ? (
<>
<ChevronDown className="w-3 h-3" />
{t('bots.collapse')}
</>
) : (
<>
<ChevronRight className="w-3 h-3" />
{t('bots.viewDetails')}
</>
)}
</button>
)}
<div className={`${styles.timestamp}`}>
{formatTime(botLog.timestamp)}
</div>
</div>
</div>
<div className={`${styles.cardText}`}>{botLog.text}</div>
{botLog.images.length > 0 && (
{/* 日志内容 - 简化显示 */}
<div className={`${styles.cardText}`}>
{expanded ? botLog.text : getShortText(botLog.text)}
</div>
{/* 图片 - 只在展开时显示 */}
{expanded && botLog.images.length > 0 && (
<PhotoProvider>
<div className={`flex flex-wrap gap-2 mt-3`}>
{botLog.images.map((item) => (
@@ -144,6 +211,13 @@ export function BotLogCard({ botLog }: { botLog: BotLog }) {
</div>
</PhotoProvider>
)}
{/* 图片数量提示 - 未展开时显示 */}
{!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>
)}
</div>
);
}

View File

@@ -13,12 +13,14 @@ import {
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { ChevronDownIcon } from 'lucide-react';
import { ChevronDownIcon, ExternalLink } from 'lucide-react';
import { debounce } from 'lodash';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/navigation';
export function BotLogListComponent({ botId }: { botId: string }) {
const { t } = useTranslation();
const router = useRouter();
const manager = useRef(new BotLogManager(botId)).current;
const [botLogList, setBotLogList] = useState<BotLog[]>([]);
const [autoFlush, setAutoFlush] = useState(true);
@@ -206,6 +208,15 @@ export function BotLogListComponent({ botId }: { botId: string }) {
</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>
{filteredLogs.map((botLog) => {

View File

@@ -17,7 +17,7 @@ import { Switch } from '@/components/ui/switch';
import { ControllerRenderProps } from 'react-hook-form';
import { Button } from '@/components/ui/button';
import { useEffect, useState } from 'react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { httpClient, systemInfo, userInfo } from '@/app/infra/http';
import {
LLMModel,
Bot,
@@ -99,8 +99,11 @@ export default function DynamicFormItemComponent({
.getProviderLLMModels()
.then((resp) => {
let models = resp.models;
// Filter out space-chat-completions models when models service is disabled
if (systemInfo.disable_models_service) {
// 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',
);

View File

@@ -1,7 +1,7 @@
'use client';
import styles from './HomeSidebar.module.css';
import { useEffect, useState, Suspense } from 'react';
import { useEffect, useState } from 'react';
import {
SidebarChild,
SidebarChildVO,
@@ -20,7 +20,6 @@ import {
Lightbulb,
LogOut,
KeyRound,
Loader2,
} from 'lucide-react';
import { useTheme } from 'next-themes';
@@ -59,7 +58,7 @@ function compareVersions(v1: string, v2: string): boolean {
}
// TODO 侧边导航栏要加动画
function HomeSidebarContent({
export default function HomeSidebar({
onSelectedChangeAction,
}: {
onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;
@@ -228,6 +227,7 @@ function HomeSidebarContent({
);
if (routeSelectChild) {
setSelectedChild(routeSelectChild);
onSelectedChangeAction(routeSelectChild);
}
}
}
@@ -483,25 +483,3 @@ function HomeSidebarContent({
</div>
);
}
function SidebarLoadingFallback() {
return (
<div className={`${styles.sidebarContainer}`}>
<div className="flex items-center justify-center h-full">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
</div>
);
}
export default function HomeSidebar({
onSelectedChangeAction,
}: {
onSelectedChangeAction: (sidebarChild: SidebarChildVO) => void;
}) {
return (
<Suspense fallback={<SidebarLoadingFallback />}>
<HomeSidebarContent onSelectedChangeAction={onSelectedChangeAction} />
</Suspense>
);
}

View File

@@ -49,6 +49,26 @@ export const sidebarConfigList = [
ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme.html',
},
}),
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"
>
<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>
),
route: '/home/monitoring',
description: t('monitoring.description'),
helpLink: {
en_US: 'https://docs.langbot.app/en/features/monitoring.html',
zh_Hans: 'https://docs.langbot.app/zh/features/monitoring.html',
},
}),
new SidebarChildVO({
id: 'knowledge',
name: t('knowledge.title'),

View File

@@ -16,6 +16,7 @@ import { useTranslation } from 'react-i18next';
import { LLMModel, EmbeddingModel } from '@/app/infra/entities/api';
import { ExtraArg, ModelType, TestResult } from '../types';
import ExtraArgsEditor from './ExtraArgsEditor';
import { userInfo } from '@/app/infra/http';
interface ModelItemProps {
model: LLMModel | EmbeddingModel;
@@ -113,10 +114,15 @@ export default function ModelItem({
}
};
// Check if popover should be disabled (space models when not logged in)
const isPopoverDisabled =
isLangBotModels && userInfo?.account_type !== 'space';
return (
<Popover
open={isEditOpen}
open={isEditOpen && !isPopoverDisabled}
onOpenChange={(open) => {
if (isPopoverDisabled) return;
if (open) {
onOpenEditModel(model.uuid);
} else {
@@ -125,7 +131,13 @@ export default function ModelItem({
}}
>
<PopoverTrigger asChild>
<div className="flex items-center justify-between py-2 px-3 rounded-md border bg-background hover:bg-accent cursor-pointer">
<div
className={`flex items-center justify-between py-2 px-3 rounded-md border bg-background ${
isPopoverDisabled
? 'cursor-not-allowed opacity-60'
: 'hover:bg-accent cursor-pointer'
}`}
>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{model.name}</span>
<Badge variant="secondary" className="text-xs">

View File

@@ -11,61 +11,46 @@ export default function ExternalKBCard({
const { t } = useTranslation();
return (
<div className={`${styles.cardContainer}`}>
<div className="w-full h-full flex flex-row items-start gap-3">
{/* Icon */}
<img
src={httpClient.getPluginIconURL(
kbCardVO.pluginAuthor,
kbCardVO.pluginName,
)}
alt="plugin icon"
className="w-16 h-16 mt-1 rounded-[8%] flex-shrink-0"
/>
{/* Info Column */}
<div className="flex flex-col flex-1 min-w-0 h-full">
{/* Top section: Name, Description and Plugin Info */}
<div className="flex flex-col gap-0">
{/* Name and Description */}
<div className={`${styles.basicInfoNameContainer}`}>
<div className={`${styles.basicInfoNameText} ${styles.bigText}`}>
{kbCardVO.name}
</div>
<div className={`${styles.basicInfoDescriptionText}`}>
{kbCardVO.description}
</div>
</div>
{/* Plugin Info */}
<div className="flex flex-row gap-2 items-center mt-1">
<svg
className="w-5 h-5 text-gray-500 dark:text-gray-400"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<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>
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
{kbCardVO.pluginAuthor} / {kbCardVO.pluginName}
</span>
<div className={`${styles.basicInfoContainer}`}>
<div className={`${styles.iconBasicInfoContainer}`}>
{/* Emoji with plugin icon badge */}
<div className="relative">
<div className={`${styles.iconEmoji}`}>
{kbCardVO.emoji || '🔗'}
</div>
{/* Plugin icon badge at bottom right */}
<img
src={httpClient.getPluginIconURL(
kbCardVO.pluginAuthor,
kbCardVO.pluginName,
)}
alt="plugin icon"
className="absolute -bottom-1 -right-1 w-5 h-5 rounded-[20%]"
/>
</div>
{/* Bottom section: Update Time */}
<div className="flex flex-row gap-2 items-center mt-auto">
<svg
className="w-5 h-5 text-gray-500 dark:text-gray-400"
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 20ZM13 12H17V14H11V7H13V12Z"></path>
</svg>
<span className="text-sm font-medium text-gray-600 dark:text-gray-400">
{t('knowledge.updateTime')}
{kbCardVO.lastUpdatedTimeAgo}
</span>
<div className={`${styles.basicInfoNameContainer}`}>
<div className={`${styles.basicInfoNameText} ${styles.bigText}`}>
{kbCardVO.name}
</div>
<div className={`${styles.basicInfoDescriptionText}`}>
{kbCardVO.description}
</div>
</div>
</div>
<div className={`${styles.basicInfoLastUpdatedTimeContainer}`}>
<svg
className={`${styles.basicInfoUpdateTimeIcon}`}
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 20ZM13 12H17V14H11V7H13V12Z"></path>
</svg>
<div className={`${styles.basicInfoUpdateTimeText}`}>
{t('knowledge.updateTime')}
{kbCardVO.lastUpdatedTimeAgo}
</div>
</div>
</div>

View File

@@ -2,6 +2,7 @@ export class ExternalKBCardVO {
id: string;
name: string;
description: string;
emoji?: string;
retrieverName: string;
retrieverConfig: Record<string, unknown>;
lastUpdatedTimeAgo: string;
@@ -12,6 +13,7 @@ export class ExternalKBCardVO {
id,
name,
description,
emoji,
retrieverName,
retrieverConfig,
lastUpdatedTimeAgo,
@@ -21,6 +23,7 @@ export class ExternalKBCardVO {
id: string;
name: string;
description: string;
emoji?: string;
retrieverName: string;
retrieverConfig: Record<string, unknown>;
lastUpdatedTimeAgo: string;
@@ -30,6 +33,7 @@ export class ExternalKBCardVO {
this.id = id;
this.name = name;
this.description = description;
this.emoji = emoji;
this.retrieverName = retrieverName;
this.retrieverConfig = retrieverConfig;
this.lastUpdatedTimeAgo = lastUpdatedTimeAgo;

View File

@@ -15,6 +15,7 @@ import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { httpClient } from '@/app/infra/http/HttpClient';
import { ExternalKnowledgeBase } from '@/app/infra/entities/api';
import EmojiPicker from '@/components/ui/emoji-picker';
import {
Dialog,
DialogContent,
@@ -54,6 +55,7 @@ const getFormSchema = (t: (key: string) => string) =>
z.object({
name: z.string().min(1, { message: t('knowledge.nameRequired') }),
description: z.string().optional(),
emoji: z.string().optional(),
plugin_author: z.string().min(1, { message: 'Please select a retriever' }),
plugin_name: z.string().min(1, { message: 'Please select a retriever' }),
retriever_name: z.string().min(1, { message: 'Please select a retriever' }),
@@ -101,6 +103,7 @@ export default function ExternalKBForm({
defaultValues: {
name: '',
description: '',
emoji: '🔗',
plugin_author: '',
plugin_name: '',
retriever_name: '',
@@ -140,6 +143,7 @@ export default function ExternalKBForm({
// Set form values
form.setValue('name', kbConfig.name);
form.setValue('description', kbConfig.description || '');
form.setValue('emoji', kbConfig.emoji || '🔗');
form.setValue('plugin_author', kbConfig.plugin_author);
form.setValue('plugin_name', kbConfig.plugin_name);
form.setValue('retriever_name', kbConfig.retriever_name);
@@ -207,6 +211,7 @@ export default function ExternalKBForm({
return {
name: kb.name,
description: kb.description,
emoji: kb.emoji || '🔗',
plugin_author: kb.plugin_author,
plugin_name: kb.plugin_name,
retriever_name: kb.retriever_name,
@@ -276,6 +281,7 @@ export default function ExternalKBForm({
const formData: ExternalKnowledgeBase = {
name: form.getValues().name,
description: form.getValues().description || '',
emoji: form.getValues().emoji,
plugin_author: form.getValues().plugin_author,
plugin_name: form.getValues().plugin_name,
retriever_name: form.getValues().retriever_name,
@@ -390,23 +396,41 @@ export default function ExternalKBForm({
className="space-y-8"
>
<div className="space-y-4">
{/* KB Name */}
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('knowledge.kbName')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* KB Name and Emoji in same row */}
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('knowledge.kbName')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
{/* KB Description */}
<FormField

View File

@@ -4,7 +4,7 @@
background-color: #fff;
border-radius: 10px;
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
padding: 1.2rem;
padding: 1rem;
cursor: pointer;
display: flex;
flex-direction: row;
@@ -32,14 +32,41 @@
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.4rem;
gap: 0.5rem;
min-width: 0;
}
.iconEmoji {
width: 3rem;
height: 3rem;
border-radius: 0.5rem;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
flex-shrink: 0;
}
:global(.dark) .iconEmoji {
background-color: #2a2a2d;
}
.iconBasicInfoContainer {
display: flex;
flex-direction: row;
gap: 0.75rem;
align-items: flex-start;
min-width: 0;
flex: 1;
}
.basicInfoNameContainer {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
flex: 1;
}
.basicInfoNameText {

View File

@@ -7,12 +7,15 @@ export default function KBCard({ kbCardVO }: { kbCardVO: KnowledgeBaseVO }) {
return (
<div className={`${styles.cardContainer}`}>
<div className={`${styles.basicInfoContainer}`}>
<div className={`${styles.basicInfoNameContainer}`}>
<div className={`${styles.basicInfoNameText} ${styles.bigText}`}>
{kbCardVO.name}
</div>
<div className={`${styles.basicInfoDescriptionText}`}>
{kbCardVO.description}
<div className={`${styles.iconBasicInfoContainer}`}>
<div className={`${styles.iconEmoji}`}>{kbCardVO.emoji || '📚'}</div>
<div className={`${styles.basicInfoNameContainer}`}>
<div className={`${styles.basicInfoNameText} ${styles.bigText}`}>
{kbCardVO.name}
</div>
<div className={`${styles.basicInfoDescriptionText}`}>
{kbCardVO.description}
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@ export interface IKnowledgeBaseVO {
embeddingModelUUID: string;
top_k: number;
lastUpdatedTimeAgo: string;
emoji?: string;
}
export class KnowledgeBaseVO implements IKnowledgeBaseVO {
@@ -14,6 +15,7 @@ export class KnowledgeBaseVO implements IKnowledgeBaseVO {
embeddingModelUUID: string;
top_k: number;
lastUpdatedTimeAgo: string;
emoji?: string;
constructor(props: IKnowledgeBaseVO) {
this.id = props.id;
@@ -22,5 +24,6 @@ export class KnowledgeBaseVO implements IKnowledgeBaseVO {
this.embeddingModelUUID = props.embeddingModelUUID;
this.top_k = props.top_k;
this.lastUpdatedTimeAgo = props.lastUpdatedTimeAgo;
this.emoji = props.emoji;
}
}

View File

@@ -4,6 +4,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useTranslation } from 'react-i18next';
import { Input } from '@/components/ui/input';
import EmojiPicker from '@/components/ui/emoji-picker';
import {
Form,
FormControl,
@@ -13,7 +14,7 @@ import {
FormMessage,
FormDescription,
} from '@/components/ui/form';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { httpClient, systemInfo, userInfo } from '@/app/infra/http';
import {
Select,
SelectContent,
@@ -32,6 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
description: z
.string()
.min(1, { message: t('knowledge.kbDescriptionRequired') }),
emoji: z.string().optional(),
embeddingModelUUID: z
.string()
.min(1, { message: t('knowledge.embeddingModelUUIDRequired') }),
@@ -58,6 +60,7 @@ export default function KBForm({
defaultValues: {
name: '',
description: t('knowledge.defaultDescription'),
emoji: '📚',
embeddingModelUUID: '',
top_k: 5,
},
@@ -71,6 +74,7 @@ export default function KBForm({
getKbConfig(initKbId).then((val) => {
form.setValue('name', val.name);
form.setValue('description', val.description);
form.setValue('emoji', val.emoji);
form.setValue('embeddingModelUUID', val.embeddingModelUUID);
form.setValue('top_k', val.top_k || 5);
});
@@ -86,6 +90,7 @@ export default function KBForm({
resolve({
name: res.base.name,
description: res.base.description,
emoji: res.base.emoji || '📚',
embeddingModelUUID: res.base.embedding_model_uuid,
top_k: res.base.top_k || 5,
});
@@ -96,8 +101,11 @@ export default function KBForm({
const getEmbeddingModelNameList = async () => {
const resp = await httpClient.getProviderEmbeddingModels();
let models = resp.models;
// Filter out space-chat-completions models when models service is disabled
if (systemInfo.disable_models_service) {
// 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',
);
@@ -111,6 +119,7 @@ export default function KBForm({
const updateKb: KnowledgeBase = {
name: data.name,
description: data.description,
emoji: data.emoji,
embedding_model_uuid: data.embeddingModelUUID,
top_k: data.top_k,
};
@@ -129,6 +138,7 @@ export default function KBForm({
const newKb: KnowledgeBase = {
name: data.name,
description: data.description,
emoji: data.emoji,
embedding_model_uuid: data.embeddingModelUUID,
top_k: data.top_k,
};
@@ -152,22 +162,41 @@ export default function KBForm({
className="space-y-8"
>
<div className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('knowledge.kbName')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Name and Emoji in same row */}
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('knowledge.kbName')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="description"

View File

@@ -70,6 +70,7 @@ export default function KnowledgePage() {
id: kb.uuid || '',
name: kb.name,
description: kb.description,
emoji: kb.emoji,
embeddingModelUUID: kb.embedding_model_uuid,
top_k: kb.top_k ?? 5,
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,
@@ -102,6 +103,7 @@ export default function KnowledgePage() {
id: kb.uuid || '',
name: kb.name,
description: kb.description,
emoji: kb.emoji,
retrieverName: `${kb.plugin_author}/${kb.plugin_name}/${kb.retriever_name}`,
retrieverConfig: kb.retriever_config || {},
lastUpdatedTimeAgo: lastUpdatedTimeAgoText,

View File

@@ -3,9 +3,16 @@
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 React, { useState, useCallback, useMemo } from 'react';
import React, {
useState,
useCallback,
useMemo,
useEffect,
Suspense,
} from 'react';
import { SidebarChildVO } from '@/app/home/components/home-sidebar/HomeSidebarChild';
import { I18nObject } from '@/app/infra/entities/common';
import { userInfo, initializeUserInfo } from '@/app/infra/http';
export default function HomeLayout({
children,
@@ -19,6 +26,13 @@ export default function HomeLayout({
zh_Hans: '',
});
// Initialize user info if not already initialized
useEffect(() => {
if (!userInfo) {
initializeUserInfo();
}
}, []);
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
setTitle(child.name);
setSubtitle(child.description);
@@ -31,7 +45,9 @@ export default function HomeLayout({
return (
<div className={styles.homeLayoutContainer}>
<aside className={styles.sidebar}>
<HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />
<Suspense fallback={<div />}>
<HomeSidebar onSelectedChangeAction={onSelectedChangeAction} />
</Suspense>
</aside>
<div className={styles.main}>

View File

@@ -0,0 +1,9 @@
import { LoadingSpinner } from '@/components/ui/loading-spinner';
export default function Loading() {
return (
<div className="flex h-full items-center justify-center">
<LoadingSpinner size="lg" />
</div>
);
}

View File

@@ -0,0 +1,230 @@
'use client';
import React, { useState } from 'react';
import {
MessageChainComponent,
Image as ImageComponent,
Plain,
At,
Voice,
Quote,
} from '@/app/infra/entities/message';
import ImagePreviewDialog from '@/app/home/pipelines/components/debug-dialog/ImagePreviewDialog';
interface MessageContentRendererProps {
content: string;
maxLines?: number;
}
export function MessageContentRenderer({
content,
maxLines = 3,
}: MessageContentRendererProps) {
const [previewImageUrl, setPreviewImageUrl] = useState<string>('');
const [showImagePreview, setShowImagePreview] = useState(false);
// Try to parse content as message_chain JSON
const parseContent = (content: string): MessageChainComponent[] | null => {
try {
const parsed = JSON.parse(content);
if (Array.isArray(parsed) && parsed.length > 0 && parsed[0].type) {
return parsed as MessageChainComponent[];
}
return null;
} catch {
return null;
}
};
const renderMessageComponent = (
component: MessageChainComponent,
index: number,
) => {
switch (component.type) {
case 'Plain':
return <span key={index}>{(component as Plain).text}</span>;
case 'At': {
const atComponent = component as At;
const displayName =
atComponent.display || atComponent.target?.toString() || '';
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
>
@{displayName}
</span>
);
}
case 'AtAll':
return (
<span
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 text-sm"
>
@All
</span>
);
case 'Image': {
const img = component as ImageComponent;
const imageUrl = img.url || (img.base64 ? img.base64 : '');
if (!imageUrl) {
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"
>
[Image]
</span>
);
}
return (
<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"
onClick={(e) => {
e.stopPropagation();
setPreviewImageUrl(imageUrl);
setShowImagePreview(true);
}}
/>
</span>
);
}
case 'File': {
const file = component as MessageChainComponent & { name?: string };
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"
>
<svg
className="w-3.5 h-3.5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M8 4a3 3 0 00-3 3v4a5 5 0 0010 0V7a1 1 0 112 0v4a7 7 0 11-14 0V7a5 5 0 0110 0v4a3 3 0 11-6 0V7a1 1 0 012 0v4a1 1 0 102 0V7a3 3 0 00-3-3z" />
</svg>
{file.name || 'File'}
</span>
);
}
case 'Voice': {
const voice = component as Voice;
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"
>
<svg
className="w-3.5 h-3.5 mr-1"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M18 3a1 1 0 00-1.196-.98l-10 2A1 1 0 006 5v9.114A4.369 4.369 0 005 14c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V7.82l8-1.6v5.894A4.37 4.37 0 0015 12c-1.657 0-3 .895-3 2s1.343 2 3 2 3-.895 3-2V3z" />
</svg>
Voice{voice.length ? ` ${voice.length}s` : ''}
</span>
);
}
case 'Quote': {
const quote = component as Quote;
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"
>
{quote.origin
?.filter((c) => (c as MessageChainComponent).type === 'Plain')
.map((c) => (c as MessageChainComponent as Plain).text)
.join('') || '[Quote]'}
</span>
);
}
case 'Source':
return null;
default:
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"
>
[{component.type}]
</span>
);
}
};
const messageChain = parseContent(content);
// Determine line clamp class
const lineClampClass =
maxLines === 2
? 'line-clamp-2'
: maxLines === 3
? 'line-clamp-3'
: maxLines === 4
? 'line-clamp-4'
: '';
if (messageChain) {
// Filter out Source components as they render to null
const visibleComponents = messageChain.filter(
(component) => component.type !== 'Source',
);
// If no visible components, show placeholder
if (visibleComponents.length === 0) {
return (
<span className="text-gray-400 dark:text-gray-500 italic">
[Empty message]
</span>
);
}
// Render as message chain
return (
<>
<div className={`${lineClampClass}`}>
{messageChain.map((component, index) =>
renderMessageComponent(component, index),
)}
</div>
<ImagePreviewDialog
open={showImagePreview}
imageUrl={previewImageUrl}
onClose={() => setShowImagePreview(false)}
/>
</>
);
}
// Handle empty plain text
if (
!content ||
content.trim() === '' ||
content === '[]' ||
content === '""'
) {
return (
<span className="text-gray-400 dark:text-gray-500 italic">
[Empty message]
</span>
);
}
// Render as plain text
return <span className={lineClampClass}>{content}</span>;
}

View File

@@ -0,0 +1,292 @@
'use client';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { MessageDetails } from '../types/monitoring';
interface MessageDetailsCardProps {
details: MessageDetails;
}
export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
const { t } = useTranslation();
// Parse query variables JSON string
const queryVariables = useMemo(() => {
if (!details.message?.variables) return null;
try {
return JSON.parse(details.message.variables);
} catch {
return null;
}
}, [details.message?.variables]);
return (
<div className="space-y-4 pl-8 border-l-2 border-gray-200 dark:border-gray-700 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">
<svg
className="w-4 h-4 mr-2"
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 7H13V9H11V7ZM11 11H13V17H11V11Z"></path>
</svg>
{t('monitoring.messageList.viewDetails')}
</h4>
{/* 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">
{t('monitoring.messageList.platform')}
</div>
<div className="font-medium text-gray-900 dark:text-white">
{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">
{t('monitoring.messageList.user')}
</div>
<div className="font-medium text-gray-900 dark:text-white 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">
{t('monitoring.messageList.runner')}
</div>
<div className="font-medium text-gray-900 dark:text-white">
{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">
{t('monitoring.messageList.level')}
</div>
<div
className={`font-medium ${
details.message.level === 'error'
? 'text-red-600 dark:text-red-400'
: details.message.level === 'warning'
? 'text-yellow-600 dark:text-yellow-400'
: 'text-gray-900 dark:text-white'
}`}
>
{details.message.level.toUpperCase()}
</div>
</div>
</div>
</div>
)}
{/* 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">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M12 2C17.52 2 22 6.48 22 12C22 17.52 17.52 22 12 22C6.48 22 2 17.52 2 12C2 6.48 6.48 2 12 2ZM12 20C16.42 20 20 16.42 20 12C20 7.58 16.42 4 12 4C7.58 4 4 7.58 4 12C4 16.42 7.58 20 12 20ZM13 12V7H11V14H17V12H13Z"></path>
</svg>
{t('monitoring.llmCalls.title')} ({details.llmCalls.length})
</h4>
{/* LLM Stats Summary */}
<div className="grid grid-cols-3 gap-2 mb-3">
<div className="bg-blue-50 dark:bg-blue-900/30 rounded p-2">
<div className="text-xs text-blue-600 dark:text-blue-400">
{t('monitoring.llmCalls.totalTokens')}
</div>
<div className="text-lg font-semibold text-blue-900 dark:text-blue-100">
{details.llmStats.totalTokens.toLocaleString()}
</div>
</div>
<div className="bg-green-50 dark:bg-green-900/30 rounded p-2">
<div className="text-xs text-green-600 dark:text-green-400">
{t('monitoring.llmCalls.avgDuration')}
</div>
<div className="text-lg font-semibold text-green-900 dark:text-green-100">
{details.llmStats.averageDurationMs}ms
</div>
</div>
<div className="bg-purple-50 dark:bg-purple-900/30 rounded p-2">
<div className="text-xs text-purple-600 dark:text-purple-400">
{t('monitoring.llmCalls.calls')}
</div>
<div className="text-lg font-semibold text-purple-900 dark:text-purple-100">
{details.llmStats.totalCalls}
</div>
</div>
</div>
{/* 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 className="flex justify-between items-start mb-2">
<div>
<span className="font-medium text-gray-900 dark:text-white">
#{index + 1} {call.modelName}
</span>
<span
className={`ml-2 text-xs px-2 py-0.5 rounded ${
call.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}
>
{call.status}
</span>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400">
{call.duration}ms
</span>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-600 dark:text-gray-400">
<div>
<span className="text-gray-500 dark:text-gray-500">
In:
</span>{' '}
{call.tokens.input.toLocaleString()}
</div>
<div>
<span className="text-gray-500 dark:text-gray-500">
Out:
</span>{' '}
{call.tokens.output.toLocaleString()}
</div>
<div>
<span className="text-gray-500 dark:text-gray-500">
Total:
</span>{' '}
{call.tokens.total.toLocaleString()}
</div>
</div>
{call.errorMessage && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
{call.errorMessage}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Errors Section */}
{details.errors && details.errors.length > 0 && (
<div className="bg-gray-50 dark:bg-gray-800 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"
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 15H13V17H11V15ZM11 7H13V13H11V7Z"></path>
</svg>
{t('monitoring.errors.title')} ({details.errors.length})
</h4>
<div className="space-y-2">
{details.errors.map((error) => (
<div
key={error.id}
className="bg-red-50 dark:bg-red-900/20 rounded p-2 text-sm"
>
<div className="font-medium text-red-900 dark:text-red-300 mb-1">
{error.errorType}
</div>
<div className="text-red-700 dark:text-red-400 text-xs mb-2">
{error.errorMessage}
</div>
{error.stackTrace && (
<details className="text-xs">
<summary className="cursor-pointer text-red-600 dark:text-red-500 hover:text-red-800 dark:hover:text-red-300">
{t('monitoring.errors.stackTrace')}
</summary>
<pre className="mt-2 p-2 bg-red-100 dark:bg-red-900/40 rounded overflow-x-auto text-xs">
{error.stackTrace}
</pre>
</details>
)}
</div>
))}
</div>
</div>
)}
{/* Query Variables Section - Only show for non-local-agent runners */}
{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">
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M4 18V14.3C4 13.4716 3.32843 12.8 2.5 12.8H2V11.2H2.5C3.32843 11.2 4 10.5284 4 9.7V6C4 4.34315 5.34315 3 7 3H8V5H7C6.44772 5 6 5.44772 6 6V9.7C6 10.7065 5.41099 11.5849 4.55132 12C5.41099 12.4151 6 13.2935 6 14.3V18C6 18.5523 6.44772 19 7 19H8V21H7C5.34315 21 4 19.6569 4 18ZM20 14.3V18C20 19.6569 18.6569 21 17 21H16V19H17C17.5523 19 18 18.5523 18 18V14.3C18 13.2935 18.589 12.4151 19.4487 12C18.589 11.5849 18 10.7065 18 9.7V6C18 5.44772 17.5523 5 17 5H16V3H17C18.6569 3 20 4.34315 20 6V9.7C20 10.5284 20.6716 11.2 21.5 11.2H22V12.8H21.5C20.6716 12.8 20 13.4716 20 14.3Z"></path>
</svg>
{t('monitoring.queryVariables.title')}
</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
className="font-medium text-gray-900 dark:text-white truncate"
title={
typeof value === 'string' ? value : JSON.stringify(value)
}
>
{value === null || value === undefined ? (
<span className="text-gray-400 italic">null</span>
) : typeof value === 'string' ? (
value || (
<span className="text-gray-400 italic">empty</span>
)
) : (
JSON.stringify(value)
)}
</div>
</div>
))}
</div>
</div>
)}
{/* No data message */}
{(!details.llmCalls || details.llmCalls.length === 0) &&
(!details.errors || details.errors.length === 0) &&
(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">
{t('monitoring.messageDetails.noData')}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,209 @@
'use client';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { backendClient } from '@/app/infra/http';
import { TimeRangeOption } from '../../types/monitoring';
interface MonitoringFiltersProps {
selectedBots: string[];
selectedPipelines: string[];
timeRange: TimeRangeOption;
onBotsChange: (bots: string[]) => void;
onPipelinesChange: (pipelines: string[]) => void;
onTimeRangeChange: (timeRange: TimeRangeOption) => void;
}
interface Bot {
uuid: string;
name: string;
}
interface Pipeline {
uuid: string;
name: string;
}
export default function MonitoringFilters({
selectedBots,
selectedPipelines,
timeRange,
onBotsChange,
onPipelinesChange,
onTimeRangeChange,
}: MonitoringFiltersProps) {
const { t } = useTranslation();
const [bots, setBots] = useState<Bot[]>([]);
const [pipelines, setPipelines] = useState<Pipeline[]>([]);
const [loadingBots, setLoadingBots] = useState(false);
const [loadingPipelines, setLoadingPipelines] = useState(false);
// Fetch bots list
useEffect(() => {
const fetchBots = async () => {
setLoadingBots(true);
try {
const response = await backendClient.getBots();
// Filter out bots without uuid and map to local Bot interface
const validBots = (response.bots || [])
.filter((bot): bot is typeof bot & { uuid: string } => !!bot.uuid)
.map((bot) => ({ uuid: bot.uuid, name: bot.name }));
setBots(validBots);
} catch (error) {
console.error('Failed to fetch bots:', error);
} finally {
setLoadingBots(false);
}
};
fetchBots();
}, []);
// Fetch pipelines list
useEffect(() => {
const fetchPipelines = async () => {
setLoadingPipelines(true);
try {
const response = await backendClient.getPipelines();
// Filter out pipelines without uuid and map to local Pipeline interface
const validPipelines = (response.pipelines || [])
.filter(
(pipeline): pipeline is typeof pipeline & { uuid: string } =>
!!pipeline.uuid,
)
.map((pipeline) => ({ uuid: pipeline.uuid, name: pipeline.name }));
setPipelines(validPipelines);
} catch (error) {
console.error('Failed to fetch pipelines:', error);
} finally {
setLoadingPipelines(false);
}
};
fetchPipelines();
}, []);
const handleBotChange = (value: string) => {
if (value === 'all') {
onBotsChange([]);
} else {
onBotsChange([value]);
}
};
const handlePipelineChange = (value: string) => {
if (value === 'all') {
onPipelinesChange([]);
} else {
onPipelinesChange([value]);
}
};
const handleTimeRangeChange = (value: string) => {
onTimeRangeChange(value as TimeRangeOption);
};
return (
<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">
{t('monitoring.filters.bot')}
</label>
<Select
value={selectedBots.length === 0 ? 'all' : selectedBots[0]}
onValueChange={handleBotChange}
disabled={loadingBots}
>
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[140px]">
<SelectValue
placeholder={
loadingBots
? t('monitoring.filters.loading')
: t('monitoring.filters.selectBot')
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.filters.allBots')}
</SelectItem>
{bots.map((bot) => (
<SelectItem key={bot.uuid} value={bot.uuid}>
{bot.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Pipeline Filter */}
<div className="flex items-center gap-2">
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 whitespace-nowrap">
{t('monitoring.filters.pipeline')}
</label>
<Select
value={selectedPipelines.length === 0 ? 'all' : selectedPipelines[0]}
onValueChange={handlePipelineChange}
disabled={loadingPipelines}
>
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[140px]">
<SelectValue
placeholder={
loadingPipelines
? t('monitoring.filters.loading')
: t('monitoring.filters.selectPipeline')
}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('monitoring.filters.allPipelines')}
</SelectItem>
{pipelines.map((pipeline) => (
<SelectItem key={pipeline.uuid} value={pipeline.uuid}>
{pipeline.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 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">
{t('monitoring.filters.timeRange')}
</label>
<Select value={timeRange} onValueChange={handleTimeRangeChange}>
<SelectTrigger className="bg-white dark:bg-[#2a2a2e] h-9 w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="lastHour">
{t('monitoring.filters.lastHour')}
</SelectItem>
<SelectItem value="last6Hours">
{t('monitoring.filters.last6Hours')}
</SelectItem>
<SelectItem value="last24Hours">
{t('monitoring.filters.last24Hours')}
</SelectItem>
<SelectItem value="last7Days">
{t('monitoring.filters.last7Days')}
</SelectItem>
<SelectItem value="last30Days">
{t('monitoring.filters.last30Days')}
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import React from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
interface MetricCardProps {
title: string;
value: string | number;
icon: React.ReactNode;
trend?: {
value: number;
direction: 'up' | 'down';
};
loading?: boolean;
}
export default function MetricCard({
title,
value,
icon,
trend,
loading,
}: 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">
<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">
{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">
<div className="h-5 w-5 text-blue-600 dark:text-blue-400">
{icon}
</div>
</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>
</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">
<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">
{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">
<div className="h-5 w-5 text-blue-600 dark:text-blue-400">{icon}</div>
</div>
</CardHeader>
<CardContent>
<div className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
{value}
</div>
{trend && (
<div className="flex items-center gap-1.5">
<span
className={`inline-flex items-center gap-1 text-xs font-medium px-2 py-0.5 rounded-full ${
trend.direction === 'up'
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}
>
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
{trend.direction === 'up' ? (
<path
fillRule="evenodd"
d="M5.293 9.707a1 1 0 010-1.414l4-4a1 1 0 011.414 0l4 4a1 1 0 01-1.414 1.414L11 7.414V15a1 1 0 11-2 0V7.414L6.707 9.707a1 1 0 01-1.414 0z"
clipRule="evenodd"
/>
) : (
<path
fillRule="evenodd"
d="M14.707 10.293a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 111.414-1.414L9 12.586V5a1 1 0 012 0v7.586l2.293-2.293a1 1 0 011.414 0z"
clipRule="evenodd"
/>
)}
</svg>
{Math.abs(trend.value)}%
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
vs previous period
</span>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,135 @@
'use client';
import React from 'react';
import { useTranslation } from 'react-i18next';
import MetricCard from './MetricCard';
import TrafficChart from './TrafficChart';
import {
OverviewMetrics,
MonitoringMessage,
LLMCall,
} from '../../types/monitoring';
interface OverviewCardsProps {
metrics: OverviewMetrics | null;
messages?: MonitoringMessage[];
llmCalls?: LLMCall[];
loading?: boolean;
}
export default function OverviewCards({
metrics,
messages = [],
llmCalls = [],
loading,
}: OverviewCardsProps) {
const { t } = useTranslation();
const cards = [
{
title: t('monitoring.totalMessages'),
value: metrics?.totalMessages || 0,
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<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>
),
trend: metrics?.trends
? {
value: metrics.trends.messages,
direction: (metrics.trends.messages >= 0 ? 'up' : 'down') as
| 'up'
| 'down',
}
: undefined,
},
{
title: t('monitoring.modelCallsCount'),
value: metrics?.modelCalls || 0,
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<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>
),
trend: metrics?.trends
? {
value: metrics.trends.llmCalls,
direction: (metrics.trends.llmCalls >= 0 ? 'up' : 'down') as
| 'up'
| 'down',
}
: undefined,
},
{
title: t('monitoring.successRate'),
value: metrics ? `${metrics.successRate}%` : '0%',
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10 15.172L19.192 5.979L20.607 7.393L10 18L3.636 11.636L5.05 10.222L10 15.172Z"></path>
</svg>
),
trend: metrics?.trends
? {
value: metrics.trends.successRate,
direction: (metrics.trends.successRate >= 0 ? 'up' : 'down') as
| 'up'
| 'down',
}
: undefined,
},
{
title: t('monitoring.activeSessions'),
value: metrics?.activeSessions || 0,
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.7519 23 22H21C21 19.3742 19.4041 17.1096 17.1582 16.2466L18.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>
),
trend: metrics?.trends
? {
value: metrics.trends.sessions,
direction: (metrics.trends.sessions >= 0 ? 'up' : 'down') as
| 'up'
| 'down',
}
: undefined,
},
];
return (
<div className="space-y-6">
{/* Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-6">
{cards.map((card, index) => (
<MetricCard
key={index}
title={card.title}
value={card.value}
icon={card.icon}
trend={card.trend}
loading={loading}
/>
))}
</div>
{/* Traffic Chart */}
<TrafficChart messages={messages} llmCalls={llmCalls} loading={loading} />
</div>
);
}

View File

@@ -0,0 +1,263 @@
'use client';
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { MonitoringMessage, LLMCall } from '../../types/monitoring';
interface TrafficChartProps {
messages: MonitoringMessage[];
llmCalls: LLMCall[];
loading?: boolean;
}
interface ChartDataPoint {
time: string;
timestamp: number;
messages: number;
llmCalls: number;
}
export default function TrafficChart({
messages,
llmCalls,
loading,
}: TrafficChartProps) {
const { t } = useTranslation();
const chartData = useMemo(() => {
if (!messages.length && !llmCalls.length) {
return [];
}
// Combine all timestamps and find the range
const allTimestamps = [
...messages.map((m) => m.timestamp.getTime()),
...llmCalls.map((c) => c.timestamp.getTime()),
];
if (allTimestamps.length === 0) return [];
const minTime = Math.min(...allTimestamps);
const maxTime = Math.max(...allTimestamps);
const timeRange = maxTime - minTime;
// Determine bucket size based on time range
let bucketSize: number;
let formatTime: (date: Date) => string;
if (timeRange <= 60 * 60 * 1000) {
// <= 1 hour: 5-minute buckets
bucketSize = 5 * 60 * 1000;
formatTime = (date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (timeRange <= 6 * 60 * 60 * 1000) {
// <= 6 hours: 15-minute buckets
bucketSize = 15 * 60 * 1000;
formatTime = (date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (timeRange <= 24 * 60 * 60 * 1000) {
// <= 24 hours: 1-hour buckets
bucketSize = 60 * 60 * 1000;
formatTime = (date) =>
date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
} else if (timeRange <= 7 * 24 * 60 * 60 * 1000) {
// <= 7 days: 4-hour buckets
bucketSize = 4 * 60 * 60 * 1000;
formatTime = (date) =>
`${date.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${date.toLocaleTimeString([], { hour: '2-digit' })}`;
} else {
// > 7 days: 1-day buckets
bucketSize = 24 * 60 * 60 * 1000;
formatTime = (date) =>
date.toLocaleDateString([], { month: 'short', day: 'numeric' });
}
// Create buckets
const buckets: Map<number, ChartDataPoint> = new Map();
const startBucket = Math.floor(minTime / bucketSize) * bucketSize;
const endBucket = Math.ceil(maxTime / bucketSize) * bucketSize;
for (let bucket = startBucket; bucket <= endBucket; bucket += bucketSize) {
buckets.set(bucket, {
time: formatTime(new Date(bucket)),
timestamp: bucket,
messages: 0,
llmCalls: 0,
});
}
// Count messages per bucket
messages.forEach((msg) => {
const bucket =
Math.floor(msg.timestamp.getTime() / bucketSize) * bucketSize;
const point = buckets.get(bucket);
if (point) {
point.messages++;
}
});
// Count LLM calls per bucket
llmCalls.forEach((call) => {
const bucket =
Math.floor(call.timestamp.getTime() / bucketSize) * bucketSize;
const point = buckets.get(bucket);
if (point) {
point.llmCalls++;
}
});
return Array.from(buckets.values()).sort(
(a, b) => a.timestamp - b.timestamp,
);
}, [messages, llmCalls]);
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="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="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>
</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>
</div>
);
}
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">
{t('monitoring.trafficChart.title')}
</h3>
<div className="h-[300px] flex flex-col items-center justify-center text-gray-400 dark:text-gray-500">
<svg
className="w-16 h-16 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="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"
/>
</svg>
<p className="text-sm font-medium">
{t('monitoring.trafficChart.noData')}
</p>
</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">
{t('monitoring.trafficChart.title')}
</h3>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 10, right: 20, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="colorMessages" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.4} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0.05} />
</linearGradient>
<linearGradient id="colorLLMCalls" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.4} />
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0.05} />
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
stroke="#e5e7eb"
className="dark:stroke-gray-700"
vertical={false}
/>
<XAxis
dataKey="time"
tick={{ fontSize: 12, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
dy={10}
/>
<YAxis
tick={{ fontSize: 12, fill: '#9ca3af' }}
tickLine={false}
axisLine={{ stroke: '#e5e7eb' }}
width={40}
allowDecimals={false}
/>
<Tooltip
contentStyle={{
backgroundColor: 'rgba(255, 255, 255, 0.98)',
border: '1px solid #e5e7eb',
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',
}}
labelStyle={{
fontWeight: 600,
marginBottom: '8px',
color: '#374151',
}}
itemStyle={{ padding: '4px 0' }}
/>
<Legend
wrapperStyle={{
fontSize: '13px',
paddingTop: '16px',
fontWeight: 500,
}}
iconType="circle"
iconSize={10}
/>
<Area
type="monotone"
dataKey="messages"
name={t('monitoring.trafficChart.messages')}
stroke="#3b82f6"
strokeWidth={2.5}
fillOpacity={1}
fill="url(#colorMessages)"
dot={false}
activeDot={{ r: 6, strokeWidth: 2 }}
/>
<Area
type="monotone"
dataKey="llmCalls"
name={t('monitoring.trafficChart.llmCalls')}
stroke="#8b5cf6"
strokeWidth={2.5}
fillOpacity={1}
fill="url(#colorLLMCalls)"
dot={false}
activeDot={{ r: 6, strokeWidth: 2 }}
/>
</AreaChart>
</ResponsiveContainer>
</div>
</div>
);
}

View File

@@ -0,0 +1,352 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import {
FilterState,
MonitoringData,
ModelCall,
LLMCall,
EmbeddingCall,
} from '../types/monitoring';
import { backendClient } from '@/app/infra/http';
/**
* Custom hook for fetching and managing monitoring data
*/
export function useMonitoringData(filterState: FilterState) {
const [data, setData] = useState<MonitoringData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
// Memoize filter parameters to prevent unnecessary re-renders
const selectedBotsStr = useMemo(
() => JSON.stringify(filterState.selectedBots),
[filterState.selectedBots],
);
const selectedPipelinesStr = useMemo(
() => JSON.stringify(filterState.selectedPipelines),
[filterState.selectedPipelines],
);
const customDateRangeStr = useMemo(
() => JSON.stringify(filterState.customDateRange),
[filterState.customDateRange],
);
// Convert time range to datetime strings
const getTimeRange = useCallback(() => {
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]);
// Fetch data based on filters
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const { startTime, endTime } = getTimeRange();
const response = await backendClient.getMonitoringData({
botId:
filterState.selectedBots.length > 0
? filterState.selectedBots
: undefined,
pipelineId:
filterState.selectedPipelines.length > 0
? filterState.selectedPipelines
: undefined,
startTime,
endTime,
limit: 50,
});
// Transform the response to match MonitoringData interface
const transformedData: MonitoringData = {
overview: {
totalMessages: response.overview.total_messages,
llmCalls: response.overview.llm_calls,
embeddingCalls: response.overview.embedding_calls || 0,
modelCalls:
response.overview.model_calls || response.overview.llm_calls,
successRate: response.overview.success_rate,
activeSessions: response.overview.active_sessions,
},
messages: response.messages.map(
(msg: {
id: string;
timestamp: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
message_content: string;
session_id: string;
status: string;
level: string;
platform?: string;
user_id?: string;
runner_name?: string;
variables?: string;
}) => ({
id: msg.id,
timestamp: new Date(msg.timestamp),
botId: msg.bot_id,
botName: msg.bot_name,
pipelineId: msg.pipeline_id,
pipelineName: msg.pipeline_name,
messageContent: msg.message_content,
sessionId: msg.session_id,
status: msg.status as 'success' | 'error' | 'pending',
level: msg.level as 'info' | 'warning' | 'error' | 'debug',
platform: msg.platform,
userId: msg.user_id,
runnerName: msg.runner_name,
variables: msg.variables,
}),
),
llmCalls: response.llmCalls.map(
(call: {
id: string;
timestamp: string;
model_name: string;
input_tokens: number;
output_tokens: number;
total_tokens: number;
duration: number;
cost?: number;
status: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
error_message?: string;
message_id?: string;
}) => ({
id: call.id,
timestamp: new Date(call.timestamp),
modelName: call.model_name,
tokens: {
input: call.input_tokens,
output: call.output_tokens,
total: call.total_tokens,
},
duration: call.duration,
cost: call.cost,
status: call.status as 'success' | 'error',
botId: call.bot_id,
botName: call.bot_name,
pipelineId: call.pipeline_id,
pipelineName: call.pipeline_name,
errorMessage: call.error_message,
messageId: call.message_id,
}),
),
embeddingCalls: (response.embeddingCalls || []).map(
(call: {
id: string;
timestamp: string;
model_name: string;
prompt_tokens: number;
total_tokens: number;
duration: number;
input_count: number;
status: string;
error_message?: string;
knowledge_base_id?: string;
query_text?: string;
session_id?: string;
message_id?: string;
call_type?: string;
}) => ({
id: call.id,
timestamp: new Date(call.timestamp),
modelName: call.model_name,
promptTokens: call.prompt_tokens,
totalTokens: call.total_tokens,
duration: call.duration,
inputCount: call.input_count,
status: call.status as 'success' | 'error',
errorMessage: call.error_message,
knowledgeBaseId: call.knowledge_base_id,
queryText: call.query_text,
sessionId: call.session_id,
messageId: call.message_id,
callType: call.call_type as 'embedding' | 'retrieve' | undefined,
}),
),
// Create merged modelCalls array from llmCalls and embeddingCalls
modelCalls: [] as ModelCall[], // Will be populated after transform
sessions: response.sessions.map(
(session: {
session_id: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
message_count: number;
last_activity: string;
start_time: string;
platform?: string;
user_id?: string;
}) => ({
sessionId: session.session_id,
botId: session.bot_id,
botName: session.bot_name,
pipelineId: session.pipeline_id,
pipelineName: session.pipeline_name,
messageCount: session.message_count,
duration:
new Date(session.last_activity).getTime() -
new Date(session.start_time).getTime(),
lastActivity: new Date(session.last_activity),
startTime: new Date(session.start_time),
platform: session.platform,
userId: session.user_id,
}),
),
errors: response.errors.map(
(error: {
id: string;
timestamp: string;
error_type: string;
error_message: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
session_id?: string;
stack_trace?: string;
message_id?: string;
}) => ({
id: error.id,
timestamp: new Date(error.timestamp),
errorType: error.error_type,
errorMessage: error.error_message,
botId: error.bot_id,
botName: error.bot_name,
pipelineId: error.pipeline_id,
pipelineName: error.pipeline_name,
sessionId: error.session_id,
stackTrace: error.stack_trace,
messageId: error.message_id,
}),
),
totalCount: {
messages: response.totalCount.messages,
llmCalls: response.totalCount.llmCalls,
embeddingCalls: response.totalCount.embeddingCalls || 0,
sessions: response.totalCount.sessions,
errors: response.totalCount.errors,
},
};
// Merge LLM calls and embedding calls into modelCalls
const llmModelCalls: ModelCall[] = transformedData.llmCalls.map(
(call: LLMCall): ModelCall => ({
id: call.id,
timestamp: call.timestamp,
modelName: call.modelName,
modelType: 'llm',
status: call.status,
duration: call.duration,
errorMessage: call.errorMessage,
messageId: call.messageId,
tokens: call.tokens,
cost: call.cost,
botId: call.botId,
botName: call.botName,
pipelineId: call.pipelineId,
pipelineName: call.pipelineName,
}),
);
const embeddingModelCalls: ModelCall[] =
transformedData.embeddingCalls.map(
(call: EmbeddingCall): ModelCall => ({
id: call.id,
timestamp: call.timestamp,
modelName: call.modelName,
modelType: 'embedding',
status: call.status,
duration: call.duration,
errorMessage: call.errorMessage,
messageId: call.messageId,
callType: call.callType,
promptTokens: call.promptTokens,
totalTokens: call.totalTokens,
inputCount: call.inputCount,
knowledgeBaseId: call.knowledgeBaseId,
queryText: call.queryText,
sessionId: call.sessionId,
}),
);
// Combine and sort by timestamp (newest first)
transformedData.modelCalls = [
...llmModelCalls,
...embeddingModelCalls,
].sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
setData(transformedData);
} catch (err) {
setError(err as Error);
console.error('Failed to fetch monitoring data:', err);
} finally {
setLoading(false);
}
}, [getTimeRange, filterState.selectedBots, filterState.selectedPipelines]);
// Fetch data when filter state changes
useEffect(() => {
fetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
selectedBotsStr,
selectedPipelinesStr,
filterState.timeRange,
customDateRangeStr,
]);
// Manual refetch function
const refetch = () => {
fetchData();
};
return {
data,
loading,
error,
refetch,
};
}

View File

@@ -0,0 +1,65 @@
import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { FilterState, TimeRangeOption, DateRange } from '../types/monitoring';
import { getPresetDateRange } from '../utils/dateUtils';
/**
* Custom hook for managing monitoring filters
*/
export function useMonitoringFilters() {
const searchParams = useSearchParams();
// Initialize filters from URL params
const [selectedBots, setSelectedBots] = useState<string[]>(() => {
const botId = searchParams.get('botId');
return botId ? [botId] : [];
});
const [selectedPipelines, setSelectedPipelines] = useState<string[]>(() => {
const pipelineId = searchParams.get('pipelineId');
return pipelineId ? [pipelineId] : [];
});
const [timeRange, setTimeRange] = useState<TimeRangeOption>('last24Hours');
const [customDateRange, setCustomDateRange] = useState<DateRange | null>(
null,
);
// Get the active date range (either preset or custom)
const getActiveDateRange = (): DateRange | null => {
if (timeRange === 'custom' && customDateRange) {
return customDateRange;
}
return getPresetDateRange(timeRange);
};
// Reset all filters
const resetFilters = () => {
setSelectedBots([]);
setSelectedPipelines([]);
setTimeRange('last24Hours');
setCustomDateRange(null);
};
// Get the current filter state
const filterState: FilterState = {
selectedBots,
selectedPipelines,
timeRange,
customDateRange,
};
return {
selectedBots,
setSelectedBots,
selectedPipelines,
setSelectedPipelines,
timeRange,
setTimeRange,
customDateRange,
setCustomDateRange,
getActiveDateRange,
resetFilters,
filterState,
};
}

View File

@@ -0,0 +1,815 @@
'use client';
import React, { Suspense, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
import OverviewCards from './components/overview-cards/OverviewCards';
import MonitoringFilters from './components/filters/MonitoringFilters';
import { useMonitoringFilters } from './hooks/useMonitoringFilters';
import { useMonitoringData } from './hooks/useMonitoringData';
import { MessageDetailsCard } from './components/MessageDetailsCard';
import { MessageContentRenderer } from './components/MessageContentRenderer';
import { MessageDetails } from './types/monitoring';
import { httpClient } from '@/app/infra/http/HttpClient';
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
interface RawMessageData {
id: string;
timestamp: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
message_content: string;
session_id: string;
status: string;
level: string;
platform: string;
user_id: string;
runner_name: string;
variables: Record<string, unknown>;
}
interface RawLLMCallData {
id: string;
timestamp: string;
model_name: string;
status: string;
duration: number;
error_message: string | null;
input_tokens: number;
output_tokens: number;
total_tokens: number;
}
interface RawLLMStatsData {
total_calls: number;
total_input_tokens: number;
total_output_tokens: number;
total_tokens: number;
total_duration_ms: number;
average_duration_ms: number;
}
interface RawErrorData {
id: string;
timestamp: string;
error_type: string;
error_message: string;
stack_trace: string | null;
}
function MonitoringPageContent() {
const { t } = useTranslation();
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
useMonitoringFilters();
const { data, loading, refetch } = useMonitoringData(filterState);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
const [messageDetails, setMessageDetails] = useState<
Record<string, MessageDetails>
>({});
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(
{},
);
// State for expanded errors
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
// State for controlled tabs
const [activeTab, setActiveTab] = useState<string>('messages');
// Function to jump to a message record
const jumpToMessage = async (messageId: string) => {
setActiveTab('messages');
// Small delay to ensure tab switch completes
setTimeout(() => {
toggleMessageExpand(messageId);
}, 100);
};
const toggleMessageExpand = async (messageId: string) => {
if (expandedMessageId === messageId) {
// Collapse
setExpandedMessageId(null);
} else {
// Expand
setExpandedMessageId(messageId);
// Fetch details if not already loaded
if (!messageDetails[messageId]) {
setLoadingDetails({ ...loadingDetails, [messageId]: true });
try {
// httpClient.get() returns the inner data directly (response.data.data)
const result = await httpClient.get<{
message_id: string;
found: boolean;
message: RawMessageData | null;
llm_calls: RawLLMCallData[];
llm_stats: RawLLMStatsData;
errors: RawErrorData[];
}>(`/api/v1/monitoring/messages/${messageId}/details`);
if (result) {
setMessageDetails((prev) => ({
...prev,
[messageId]: {
messageId: result.message_id,
found: result.found,
message: result.message
? {
id: result.message.id,
timestamp: new Date(result.message.timestamp),
botId: result.message.bot_id,
botName: result.message.bot_name,
pipelineId: result.message.pipeline_id,
pipelineName: result.message.pipeline_name,
messageContent: result.message.message_content,
sessionId: result.message.session_id,
status: result.message.status,
level: result.message.level,
platform: result.message.platform,
userId: result.message.user_id,
runnerName: result.message.runner_name,
variables: result.message.variables,
}
: undefined,
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
id: call.id,
timestamp: new Date(call.timestamp),
modelName: call.model_name,
status: call.status,
duration: call.duration,
errorMessage: call.error_message,
tokens: {
input: call.input_tokens || 0,
output: call.output_tokens || 0,
total: call.total_tokens || 0,
},
})),
errors: result.errors.map((error: RawErrorData) => ({
id: error.id,
timestamp: new Date(error.timestamp),
errorType: error.error_type,
errorMessage: error.error_message,
stackTrace: error.stack_trace,
})),
llmStats: {
totalCalls: result.llm_stats.total_calls,
totalInputTokens: result.llm_stats.total_input_tokens,
totalOutputTokens: result.llm_stats.total_output_tokens,
totalTokens: result.llm_stats.total_tokens,
totalDurationMs: result.llm_stats.total_duration_ms,
averageDurationMs: result.llm_stats.average_duration_ms,
},
} as MessageDetails,
}));
}
} catch (error) {
console.error('Failed to fetch message details:', error);
} finally {
setLoadingDetails({ ...loadingDetails, [messageId]: false });
}
}
}
};
const toggleErrorExpand = (errorId: string) => {
if (expandedErrorId === errorId) {
setExpandedErrorId(null);
} else {
setExpandedErrorId(errorId);
}
};
return (
<div className="w-full h-full">
{/* 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="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">
<MonitoringFilters
selectedBots={filterState.selectedBots}
selectedPipelines={filterState.selectedPipelines}
timeRange={filterState.timeRange}
onBotsChange={setSelectedBots}
onPipelinesChange={setSelectedPipelines}
onTimeRangeChange={setTimeRange}
/>
<Button
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"
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path>
</svg>
{t('monitoring.refreshData')}
</Button>
</div>
</div>
</div>
{/* Content Area */}
<div className="flex flex-col gap-6 px-[0.8rem] pb-4">
{/* Overview Section */}
<OverviewCards
metrics={data?.overview || null}
messages={data?.messages || []}
llmCalls={data?.llmCalls || []}
loading={loading}
/>
{/* Tabs Section */}
<div className="bg-white dark:bg-[#2a2a2e] rounded-xl border border-gray-200 dark:border-gray-700 shadow-sm 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"
>
{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"
>
{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"
>
{t('monitoring.tabs.errors')}
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="messages" className="p-6 m-0">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner
text={t('monitoring.messageList.loading')}
/>
</div>
)}
{!loading &&
data &&
data.messages &&
data.messages.length > 0 && (
<div className="space-y-4">
{data.messages
.filter((msg) => {
// Filter out messages with empty content
const content = msg.messageContent?.trim();
return (
content && content !== '[]' && content !== '""'
);
})
.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"
>
{/* Message Header - Always Visible */}
<div
className="p-5 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
onClick={() => toggleMessageExpand(msg.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
{/* Expand Icon */}
<div className="mr-3 mt-0.5">
{expandedMessageId === msg.id ? (
<ChevronDown className="w-5 h-5 text-gray-500" />
) : (
<ChevronRight className="w-5 h-5 text-gray-500" />
)}
</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">
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">
{msg.botName}
</span>
<span className="text-gray-400"></span>
<span className="text-sm text-gray-600 dark:text-gray-400">
{msg.pipelineName}
</span>
{msg.runnerName && (
<>
<span className="text-gray-400">
</span>
<span className="text-sm text-gray-600 dark:text-gray-400">
{msg.runnerName}
</span>
</>
)}
</div>
<div className="text-base text-gray-800 dark:text-gray-200">
<MessageContentRenderer
content={msg.messageContent}
maxLines={3}
/>
</div>
</div>
</div>
{/* 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">
{msg.timestamp.toLocaleString()}
</span>
<span
className={`text-xs px-2 py-1 rounded ${
msg.level === 'error'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: msg.level === 'warning'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
}`}
>
{msg.level}
</span>
</div>
</div>
</div>
{/* 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">
{loadingDetails[msg.id] && (
<div className="py-4 flex justify-center">
<LoadingSpinner size="sm" text="" />
</div>
)}
{!loadingDetails[msg.id] &&
messageDetails[msg.id] && (
<MessageDetailsCard
details={messageDetails[msg.id]}
/>
)}
</div>
)}
</div>
))}
</div>
)}
{!loading &&
(!data || !data.messages || data.messages.length === 0) && (
<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="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"
/>
</svg>
<p className="text-base font-medium mb-2">
{t('monitoring.messageList.noMessages')}
</p>
<p className="text-sm">
{t('monitoring.messageList.noMessagesDescription')}
</p>
</div>
)}
</div>
</TabsContent>
<TabsContent value="modelCalls" className="p-6 m-0">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading &&
data &&
data.modelCalls &&
data.modelCalls.length > 0 && (
<div className="space-y-4">
{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"
>
<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">
Query ID: {call.messageId}
</span>
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={() =>
jumpToMessage(call.messageId!)
}
>
<ExternalLink className="w-3 h-3 mr-1" />
{t(
'monitoring.messageList.viewConversation',
)}
</Button>
</div>
)}
<div className="flex items-center gap-2 mb-2">
{/* Model Type Badge */}
<span
className={`text-xs px-2 py-1 rounded ${
call.modelType === 'llm'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}`}
>
{call.modelType === 'llm'
? t('monitoring.modelCalls.llmModel')
: t('monitoring.modelCalls.embeddingModel')}
</span>
{/* Call Type Badge for Embedding */}
{call.modelType === 'embedding' &&
call.callType && (
<span
className={`text-xs px-2 py-1 rounded ${
call.callType === 'retrieve'
? 'bg-cyan-100 text-cyan-800 dark:bg-cyan-900 dark:text-cyan-200'
: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200'
}`}
>
{call.callType === 'retrieve'
? t(
'monitoring.modelCalls.retrieveCall',
)
: t(
'monitoring.modelCalls.embeddingCall',
)}
</span>
)}
{/* Status Badge */}
<span
className={`text-xs px-2 py-1 rounded ${
call.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}
>
{call.status}
</span>
</div>
{/* Model Name */}
<div className="font-medium text-sm text-gray-700 dark:text-gray-300 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">
{call.botName} {call.pipelineName}
</div>
)}
{/* Token Info */}
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div className="flex flex-wrap gap-4">
{call.modelType === 'llm' && call.tokens && (
<>
<span>
{t('monitoring.llmCalls.inputTokens')}:{' '}
{call.tokens.input}
</span>
<span>
{t('monitoring.llmCalls.outputTokens')}:{' '}
{call.tokens.output}
</span>
<span>
{t('monitoring.llmCalls.totalTokens')}:{' '}
{call.tokens.total}
</span>
</>
)}
{call.modelType === 'embedding' && (
<>
<span>
{t(
'monitoring.embeddingCalls.promptTokens',
)}
: {call.promptTokens}
</span>
<span>
{t(
'monitoring.embeddingCalls.totalTokens',
)}
: {call.totalTokens}
</span>
<span>
{t(
'monitoring.embeddingCalls.inputCount',
)}
: {call.inputCount}
</span>
</>
)}
<span>
{t('monitoring.llmCalls.duration')}:{' '}
{call.duration}ms
</span>
{call.cost && (
<span>
{t('monitoring.llmCalls.cost')}: $
{call.cost.toFixed(4)}
</span>
)}
</div>
{/* Knowledge Base Info for Embedding */}
{call.modelType === 'embedding' &&
call.knowledgeBaseId && (
<div>
{t(
'monitoring.embeddingCalls.knowledgeBase',
)}
: {call.knowledgeBaseId}
</div>
)}
{/* 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">
{t(
'monitoring.embeddingCalls.queryText',
)}
:{' '}
</span>
<span className="text-gray-700 dark:text-gray-300">
{call.queryText.length > 100
? call.queryText.substring(0, 100) +
'...'
: call.queryText}
</span>
</div>
)}
</div>
{call.errorMessage && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
Error: {call.errorMessage}
</div>
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
{call.timestamp.toLocaleString()}
</span>
</div>
</div>
))}
</div>
)}
{!loading &&
(!data ||
!data.modelCalls ||
data.modelCalls.length === 0) && (
<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="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"
/>
</svg>
<p className="text-base font-medium">
{t('monitoring.modelCalls.noData')}
</p>
</div>
)}
</div>
</TabsContent>
<TabsContent value="errors" className="p-6 m-0">
<div>
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading && data && data.errors && data.errors.length > 0 && (
<div className="space-y-4">
{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"
>
{/* Error Header - Always Visible */}
<div
className="p-5 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30"
onClick={() => toggleErrorExpand(error.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
{/* Expand Icon */}
<div className="mr-3 mt-0.5">
{expandedErrorId === error.id ? (
<ChevronDown className="w-5 h-5 text-red-500" />
) : (
<ChevronRight className="w-5 h-5 text-red-500" />
)}
</div>
{/* Error Info */}
<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">
Query ID: {error.messageId || '-'}
</span>
{error.messageId && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={(e) => {
e.stopPropagation();
jumpToMessage(error.messageId!);
}}
>
<ExternalLink className="w-3 h-3 mr-1" />
{t(
'monitoring.messageList.viewConversation',
)}
</Button>
)}
</div>
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm text-red-700 dark:text-red-300">
{error.errorType}
</span>
<span className="text-red-400"></span>
<span className="text-sm text-gray-600 dark:text-gray-400">
{error.botName}
</span>
<span className="text-red-400"></span>
<span className="text-sm text-gray-600 dark:text-gray-400">
{error.pipelineName}
</span>
</div>
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
{error.errorMessage}
</p>
</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">
{error.timestamp.toLocaleString()}
</span>
</div>
</div>
</div>
{/* 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="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">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-3">
{t('monitoring.errors.errorMessage')}
</h4>
<div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words">
{error.errorMessage}
</div>
</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">
{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">
{t('monitoring.messageList.bot')}
</div>
<div className="font-medium text-gray-900 dark:text-white">
{error.botName}
</div>
</div>
<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">
{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">
{t('monitoring.sessions.sessionId')}
</div>
<div className="font-medium text-gray-900 dark:text-white truncate">
{error.sessionId}
</div>
</div>
)}
</div>
</div>
{/* 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">
{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">
{error.stackTrace}
</pre>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="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"
/>
</svg>
<p className="text-base font-medium text-green-600 dark:text-green-400">
{t('monitoring.errors.noErrors')}
</p>
</div>
)}
</div>
</TabsContent>
</Tabs>
</div>
</div>
</div>
);
}
export default function MonitoringPage() {
return (
<Suspense fallback={<LoadingPage />}>
<MonitoringPageContent />
</Suspense>
);
}

View File

@@ -0,0 +1,180 @@
export interface MonitoringMessage {
id: string;
timestamp: Date;
botId: string;
botName: string;
pipelineId: string;
pipelineName: string;
messageContent: string;
sessionId: string;
status: 'success' | 'error' | 'pending';
level: 'info' | 'warning' | 'error' | 'debug';
platform?: string;
userId?: string;
runnerName?: string;
variables?: string;
}
export interface LLMCall {
id: string;
timestamp: Date;
modelName: string;
tokens: {
input: number;
output: number;
total: number;
};
duration: number;
cost?: number;
status: 'success' | 'error';
botId: string;
botName: string;
pipelineId: string;
pipelineName: string;
errorMessage?: string;
messageId?: string;
}
export interface EmbeddingCall {
id: string;
timestamp: Date;
modelName: string;
promptTokens: number;
totalTokens: number;
duration: number;
inputCount: number;
status: 'success' | 'error';
errorMessage?: string;
knowledgeBaseId?: string;
queryText?: string;
sessionId?: string;
messageId?: string;
callType?: 'embedding' | 'retrieve';
}
// Unified model call type for displaying LLM and Embedding calls together
export interface ModelCall {
id: string;
timestamp: Date;
modelName: string;
modelType: 'llm' | 'embedding';
status: 'success' | 'error';
duration: number;
errorMessage?: string;
messageId?: string;
// LLM specific fields
tokens?: {
input: number;
output: number;
total: number;
};
cost?: number;
botId?: string;
botName?: string;
pipelineId?: string;
pipelineName?: string;
// Embedding specific fields
callType?: 'embedding' | 'retrieve';
promptTokens?: number;
totalTokens?: number;
inputCount?: number;
knowledgeBaseId?: string;
queryText?: string;
sessionId?: string;
}
export interface SessionInfo {
sessionId: string;
botId: string;
botName: string;
pipelineId: string;
pipelineName: string;
messageCount: number;
duration: number;
lastActivity: Date;
startTime: Date;
platform?: string;
userId?: string;
}
export interface ErrorLog {
id: string;
timestamp: Date;
errorType: string;
errorMessage: string;
botId: string;
botName: string;
pipelineId: string;
pipelineName: string;
sessionId?: string;
stackTrace?: string;
messageId?: string;
}
export interface MessageDetails {
messageId: string;
found: boolean;
message?: MonitoringMessage;
llmCalls: LLMCall[];
llmStats: {
totalCalls: number;
totalInputTokens: number;
totalOutputTokens: number;
totalTokens: number;
totalDurationMs: number;
averageDurationMs: number;
};
errors: ErrorLog[];
}
export interface OverviewMetrics {
totalMessages: number;
llmCalls: number;
embeddingCalls: number;
modelCalls: number;
successRate: number;
activeSessions: number;
trends?: {
messages: number;
llmCalls: number;
successRate: number;
sessions: number;
};
}
export interface FilterState {
selectedBots: string[];
selectedPipelines: string[];
timeRange: TimeRangeOption;
customDateRange: DateRange | null;
}
export type TimeRangeOption =
| 'lastHour'
| 'last6Hours'
| 'last24Hours'
| 'last7Days'
| 'last30Days'
| 'custom';
export interface DateRange {
from: Date;
to: Date;
}
export interface MonitoringData {
overview: OverviewMetrics;
messages: MonitoringMessage[];
llmCalls: LLMCall[];
embeddingCalls: EmbeddingCall[];
modelCalls: ModelCall[];
sessions: SessionInfo[];
errors: ErrorLog[];
totalCount: {
messages: number;
llmCalls: number;
embeddingCalls: number;
sessions: number;
errors: number;
};
}

View File

@@ -0,0 +1,99 @@
import { DateRange, TimeRangeOption } from '../types/monitoring';
/**
* Get date range based on preset time range option
*/
export function getPresetDateRange(option: TimeRangeOption): DateRange | null {
if (option === 'custom') return null;
const now = new Date();
const from = new Date();
switch (option) {
case 'lastHour':
from.setHours(now.getHours() - 1);
break;
case 'last6Hours':
from.setHours(now.getHours() - 6);
break;
case 'last24Hours':
from.setHours(now.getHours() - 24);
break;
case 'last7Days':
from.setDate(now.getDate() - 7);
break;
case 'last30Days':
from.setDate(now.getDate() - 30);
break;
default:
return null;
}
return { from, to: now };
}
/**
* Format timestamp to readable string
*/
export function formatTimestamp(date: Date): string {
const now = new Date();
const diff = now.getTime() - date.getTime();
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (seconds < 60) return `${seconds}s ago`;
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 7) return `${days}d ago`;
return date.toLocaleString();
}
/**
* Format date to YYYY-MM-DD
*/
export function formatDate(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
/**
* Format date to YYYY-MM-DD HH:MM:SS
*/
export function formatDateTime(date: Date): string {
const dateStr = formatDate(date);
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${dateStr} ${hours}:${minutes}:${seconds}`;
}
/**
* Format duration in seconds to readable string
*/
export function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
const hours = Math.floor(minutes / 60);
return `${hours}h ${minutes % 60}m`;
}
/**
* Check if date is within range
*/
export function isDateInRange(date: Date, range: DateRange | null): boolean {
if (!range) return true;
return date >= range.from && date <= range.to;
}
/**
* Parse date string to Date object
*/
export function parseDate(dateStr: string): Date {
return new Date(dateStr);
}

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/navigation';
import {
Dialog,
DialogContent,
@@ -19,6 +20,7 @@ import {
import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent';
import DebugDialog from './components/debug-dialog/DebugDialog';
import PipelineExtension from './components/pipeline-extensions/PipelineExtension';
import PipelineMonitoringTab from './components/monitoring-tab/PipelineMonitoringTab';
interface PipelineDialogProps {
open: boolean;
@@ -32,7 +34,7 @@ interface PipelineDialogProps {
onCancel: () => void;
}
type DialogMode = 'config' | 'debug' | 'extensions';
type DialogMode = 'config' | 'debug' | 'extensions' | 'monitoring';
export default function PipelineDialog({
open,
@@ -45,6 +47,7 @@ export default function PipelineDialog({
onCancel,
}: PipelineDialogProps) {
const { t } = useTranslation();
const router = useRouter();
const [pipelineId, setPipelineId] = useState<string | undefined>(
propPipelineId,
);
@@ -108,6 +111,19 @@ export default function PipelineDialog({
</svg>
),
},
{
key: 'monitoring',
label: t('pipelines.monitoring.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<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>
),
},
];
const getDialogTitle = () => {
@@ -119,6 +135,9 @@ export default function PipelineDialog({
if (currentMode === 'extensions') {
return t('pipelines.extensions.title');
}
if (currentMode === 'monitoring') {
return t('pipelines.monitoring.title');
}
return t('pipelines.debugDialog.title');
};
@@ -240,6 +259,16 @@ export default function PipelineDialog({
onConnectionStatusChange={setIsWebSocketConnected}
/>
)}
{currentMode === 'monitoring' && pipelineId && (
<PipelineMonitoringTab
pipelineId={pipelineId}
onNavigateToMonitoring={() => {
router.push(`/home/monitoring?pipelineId=${pipelineId}`);
onOpenChange(false);
}}
/>
)}
</div>
</main>
</SidebarProvider>

View File

@@ -0,0 +1,665 @@
'use client';
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
import { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData';
import { MessageContentRenderer } from '@/app/home/monitoring/components/MessageContentRenderer';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { httpClient } from '@/app/infra/http/HttpClient';
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
interface PipelineMonitoringTabProps {
pipelineId: string;
onNavigateToMonitoring?: () => void;
}
interface RawMessageData {
id: string;
timestamp: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
message_content: string;
session_id: string;
status: string;
level: string;
platform: string;
user_id: string;
runner_name: string;
variables: Record<string, unknown>;
}
interface RawLLMCallData {
id: string;
timestamp: string;
model_name: string;
status: string;
duration: number;
error_message: string | null;
input_tokens: number;
output_tokens: number;
total_tokens: number;
}
interface RawLLMStatsData {
total_calls: number;
total_input_tokens: number;
total_output_tokens: number;
total_tokens: number;
total_duration_ms: number;
average_duration_ms: number;
}
interface RawErrorData {
id: string;
timestamp: string;
error_type: string;
error_message: string;
stack_trace: string | null;
}
export default function PipelineMonitoringTab({
pipelineId,
onNavigateToMonitoring,
}: PipelineMonitoringTabProps) {
const { t } = useTranslation();
// Filter state - only show data for this pipeline, last 24 hours
const filterState = useMemo(
() => ({
selectedBots: [],
selectedPipelines: [pipelineId],
timeRange: 'last24Hours' as const,
customDateRange: null,
}),
[pipelineId],
);
const { data, loading, refetch } = useMonitoringData(filterState);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
const [messageDetails, setMessageDetails] = useState<
Record<string, MessageDetails>
>({});
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(
{},
);
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>('messages');
const toggleMessageExpand = async (messageId: string) => {
if (expandedMessageId === messageId) {
setExpandedMessageId(null);
} else {
setExpandedMessageId(messageId);
if (!messageDetails[messageId]) {
setLoadingDetails((prev) => ({ ...prev, [messageId]: true }));
try {
const result = await httpClient.get<{
message_id: string;
found: boolean;
message: RawMessageData | null;
llm_calls: RawLLMCallData[];
llm_stats: RawLLMStatsData;
errors: RawErrorData[];
}>(`/api/v1/monitoring/messages/${messageId}/details`);
if (result) {
setMessageDetails((prev) => ({
...prev,
[messageId]: {
messageId: result.message_id,
found: result.found,
message: result.message
? {
id: result.message.id,
timestamp: new Date(result.message.timestamp),
botId: result.message.bot_id,
botName: result.message.bot_name,
pipelineId: result.message.pipeline_id,
pipelineName: result.message.pipeline_name,
messageContent: result.message.message_content,
sessionId: result.message.session_id,
status: result.message.status,
level: result.message.level,
platform: result.message.platform,
userId: result.message.user_id,
runnerName: result.message.runner_name,
variables: result.message.variables,
}
: undefined,
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
id: call.id,
timestamp: new Date(call.timestamp),
modelName: call.model_name,
status: call.status,
duration: call.duration,
errorMessage: call.error_message,
tokens: {
input: call.input_tokens || 0,
output: call.output_tokens || 0,
total: call.total_tokens || 0,
},
})),
errors: result.errors.map((error: RawErrorData) => ({
id: error.id,
timestamp: new Date(error.timestamp),
errorType: error.error_type,
errorMessage: error.error_message,
stackTrace: error.stack_trace,
})),
llmStats: {
totalCalls: result.llm_stats.total_calls,
totalInputTokens: result.llm_stats.total_input_tokens,
totalOutputTokens: result.llm_stats.total_output_tokens,
totalTokens: result.llm_stats.total_tokens,
totalDurationMs: result.llm_stats.total_duration_ms,
averageDurationMs: result.llm_stats.average_duration_ms,
},
} as MessageDetails,
}));
}
} catch (error) {
console.error('Failed to fetch message details:', error);
} finally {
setLoadingDetails((prev) => ({ ...prev, [messageId]: false }));
}
}
}
};
const toggleErrorExpand = (errorId: string) => {
if (expandedErrorId === errorId) {
setExpandedErrorId(null);
} else {
setExpandedErrorId(errorId);
}
};
const jumpToMessage = async (messageId: string) => {
setActiveTab('messages');
// Small delay to ensure tab transition completes before expanding
setTimeout(() => {
toggleMessageExpand(messageId);
}, 100);
};
return (
<div className="w-full h-full flex flex-col">
{/* Header with refresh button */}
<div className="flex items-center justify-between mb-4 pb-4 border-b border-gray-200 dark:border-gray-700">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t('pipelines.monitoring.description')}
</p>
<div className="flex items-center gap-2">
{onNavigateToMonitoring && (
<Button
variant="outline"
size="sm"
onClick={onNavigateToMonitoring}
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600"
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M10 6V8H5V19H16V14H18V20C18 20.5523 17.5523 21 17 21H4C3.44772 21 3 20.5523 3 20V7C3 6.44772 3.44772 6 4 6H10ZM21 3V11H19V6.413L11.2071 14.2071L9.79289 12.7929L17.585 5H13V3H21Z"></path>
</svg>
{t('pipelines.monitoring.detailedLogs')}
</Button>
)}
<Button
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"
>
<svg
className="w-4 h-4 mr-2"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M5.46257 4.43262C7.21556 2.91688 9.5007 2 12 2C17.5228 2 22 6.47715 22 12C22 14.1361 21.3302 16.1158 20.1892 17.7406L17 12H20C20 7.58172 16.4183 4 12 4C9.84982 4 7.89777 4.84827 6.46023 6.22842L5.46257 4.43262ZM18.5374 19.5674C16.7844 21.0831 14.4993 22 12 22C6.47715 22 2 17.5228 2 12C2 9.86386 2.66979 7.88416 3.8108 6.25944L7 12H4C4 16.4183 7.58172 20 12 20C14.1502 20 16.1022 19.1517 17.5398 17.7716L18.5374 19.5674Z"></path>
</svg>
{t('monitoring.refreshData')}
</Button>
</div>
</div>
{/* Overview Stats */}
{data && (
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-white dark:bg-[#2a2a2e] rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('monitoring.totalMessages')}
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{data.overview.totalMessages}
</div>
</div>
<div className="bg-white dark:bg-[#2a2a2e] rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('monitoring.successRate')}
</div>
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">
{data.overview.successRate.toFixed(1)}%
</div>
</div>
<div className="bg-white dark:bg-[#2a2a2e] rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="text-sm text-gray-500 dark:text-gray-400">
{t('monitoring.tabs.errors')}
</div>
<div className="text-2xl font-bold text-red-600 dark:text-red-400 mt-1">
{data.errors.length}
</div>
</div>
</div>
)}
{/* Tabs */}
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="bg-gray-100 dark:bg-[#1a1a1e] h-10 p-1 mb-4">
<TabsTrigger
value="messages"
className="px-4 py-1.5 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm"
>
{t('monitoring.tabs.messages')}
</TabsTrigger>
<TabsTrigger
value="errors"
className="px-4 py-1.5 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm"
>
{t('monitoring.tabs.errors')}
</TabsTrigger>
<TabsTrigger
value="llmCalls"
className="px-4 py-1.5 text-sm font-medium cursor-pointer data-[state=active]:bg-white dark:data-[state=active]:bg-[#2a2a2e] data-[state=active]:shadow-sm"
>
{t('monitoring.tabs.modelCalls')}
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto min-h-0">
{/* Messages Tab */}
<TabsContent value="messages" className="m-0 h-full">
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('monitoring.messageList.loading')} />
</div>
)}
{!loading && data && data.messages && data.messages.length > 0 && (
<div className="space-y-3">
{data.messages
.filter((msg) => {
const content = msg.messageContent?.trim();
return content && content !== '[]' && content !== '""';
})
.map((msg) => (
<div
key={msg.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-all duration-200"
>
<div
className="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
onClick={() => toggleMessageExpand(msg.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
<div className="mr-2 mt-0.5">
{expandedMessageId === msg.id ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span
className={`text-xs px-2 py-0.5 rounded ${
msg.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: msg.status === 'error'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
}`}
>
{msg.status}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{msg.botName}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-2">
<MessageContentRenderer
content={msg.messageContent}
/>
</div>
</div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
{msg.timestamp.toLocaleString()}
</span>
</div>
</div>
{expandedMessageId === msg.id && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
{loadingDetails[msg.id] && (
<div className="flex justify-center py-8">
<LoadingSpinner
text={t('monitoring.messageList.loading')}
/>
</div>
)}
{!loadingDetails[msg.id] &&
messageDetails[msg.id] && (
<div className="space-y-4">
{messageDetails[msg.id].errors.length > 0 && (
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
{t('monitoring.errors.errorMessage')}
</h4>
{messageDetails[msg.id].errors.map(
(error) => (
<div
key={error.id}
className="text-sm space-y-2"
>
<div className="text-red-600 dark:text-red-400">
{error.errorType}:{' '}
{error.errorMessage}
</div>
{error.stackTrace && (
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-40 bg-white dark:bg-gray-900 p-2 rounded whitespace-pre-wrap break-words">
{error.stackTrace}
</pre>
)}
</div>
),
)}
</div>
)}
{messageDetails[msg.id].llmCalls.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-blue-700 dark:text-blue-400 mb-2">
{t('monitoring.tabs.modelCalls')} (
{messageDetails[msg.id].llmCalls.length})
</h4>
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div>
{t('monitoring.llmCalls.totalTokens')}:{' '}
{
messageDetails[msg.id].llmStats
.totalTokens
}
</div>
<div>
{t('monitoring.llmCalls.duration')}:{' '}
{messageDetails[
msg.id
].llmStats.totalDurationMs.toFixed(0)}
ms
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{!loading &&
(!data || !data.messages || data.messages.length === 0) && (
<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="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"
/>
</svg>
<p className="text-base font-medium">
{t('monitoring.messageList.noMessages')}
</p>
</div>
)}
</TabsContent>
{/* Errors Tab */}
<TabsContent value="errors" className="m-0 h-full">
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading && data && data.errors && data.errors.length > 0 && (
<div className="space-y-3">
{data.errors.map((error) => (
<div
key={error.id}
className="border border-red-200 dark:border-red-900 rounded-lg overflow-hidden hover:shadow-md transition-all duration-200"
>
<div
className="p-4 cursor-pointer hover:bg-red-50 dark:hover:bg-red-950/50 transition-colors bg-red-50/50 dark:bg-red-950/30"
onClick={() => toggleErrorExpand(error.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
<div className="mr-2 mt-0.5">
{expandedErrorId === error.id ? (
<ChevronDown className="w-4 h-4 text-red-500" />
) : (
<ChevronRight className="w-4 h-4 text-red-500" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
{error.messageId && (
<Button
variant="ghost"
size="sm"
className="h-5 px-1.5 text-xs"
onClick={(e) => {
e.stopPropagation();
jumpToMessage(error.messageId!);
}}
>
<ExternalLink className="w-3 h-3 mr-1" />
{t('monitoring.messageList.viewConversation')}
</Button>
)}
</div>
<div className="font-medium text-sm text-red-700 dark:text-red-300 mb-1">
{error.errorType}
</div>
<p className="text-sm text-red-600 dark:text-red-400 line-clamp-2">
{error.errorMessage}
</p>
</div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
{error.timestamp.toLocaleString()}
</span>
</div>
</div>
{expandedErrorId === error.id && (
<div className="border-t border-red-200 dark:border-red-900 p-4 bg-white dark:bg-gray-900">
<div className="space-y-3">
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
{t('monitoring.errors.errorMessage')}
</h4>
<div className="text-sm text-red-600 dark:text-red-400 whitespace-pre-wrap break-words">
{error.errorMessage}
</div>
</div>
{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-2">
{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-2 rounded whitespace-pre-wrap break-words">
{error.stackTrace}
</pre>
</div>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<svg
className="w-16 h-16 mx-auto mb-4 text-green-300 dark:text-green-600"
fill="none"
viewBox="0 0 24 24"
stroke="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"
/>
</svg>
<p className="text-base font-medium text-green-600 dark:text-green-400">
{t('monitoring.errors.noErrors')}
</p>
</div>
)}
</TabsContent>
{/* LLM Calls Tab */}
<TabsContent value="llmCalls" className="m-0 h-full">
{loading && (
<div className="py-12 flex justify-center">
<LoadingSpinner text={t('common.loading')} />
</div>
)}
{!loading && data && data.llmCalls && data.llmCalls.length > 0 && (
<div className="space-y-3">
{data.llmCalls.map((call) => (
<div
key={call.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-all duration-200"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span
className={`text-xs px-2 py-0.5 rounded ${
call.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
}`}
>
{call.status}
</span>
</div>
<div className="font-medium text-sm text-gray-700 dark:text-gray-300 mb-2">
{call.modelName}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div className="flex flex-wrap gap-4">
<span>
{t('monitoring.llmCalls.inputTokens')}:{' '}
{call.tokens.input}
</span>
<span>
{t('monitoring.llmCalls.outputTokens')}:{' '}
{call.tokens.output}
</span>
<span>
{t('monitoring.llmCalls.totalTokens')}:{' '}
{call.tokens.total}
</span>
<span>
{t('monitoring.llmCalls.duration')}:{' '}
{call.duration}ms
</span>
{call.cost && (
<span>
{t('monitoring.llmCalls.cost')}: $
{call.cost.toFixed(4)}
</span>
)}
</div>
</div>
{call.errorMessage && (
<div className="mt-2 text-xs text-red-600 dark:text-red-400">
Error: {call.errorMessage}
</div>
)}
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
{call.timestamp.toLocaleString()}
</span>
</div>
</div>
))}
</div>
)}
{!loading &&
(!data || !data.llmCalls || data.llmCalls.length === 0) && (
<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="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
<p className="text-base font-medium">
{t('monitoring.llmCalls.noData')}
</p>
</div>
)}
</TabsContent>
</div>
</Tabs>
</div>
);
}

View File

@@ -8,12 +8,15 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
return (
<div className={`${styles.cardContainer}`}>
<div className={`${styles.basicInfoContainer}`}>
<div className={`${styles.basicInfoNameContainer}`}>
<div className={`${styles.basicInfoNameText} ${styles.bigText}`}>
{cardVO.name}
</div>
<div className={`${styles.basicInfoDescriptionText}`}>
{cardVO.description}
<div className={`${styles.iconBasicInfoContainer}`}>
<div className={`${styles.iconEmoji}`}>{cardVO.emoji || '⚙️'}</div>
<div className={`${styles.basicInfoNameContainer}`}>
<div className={`${styles.basicInfoNameText} ${styles.bigText}`}>
{cardVO.name}
</div>
<div className={`${styles.basicInfoDescriptionText}`}>
{cardVO.description}
</div>
</div>
</div>
@@ -33,8 +36,8 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
</div>
</div>
<div className={styles.operationContainer}>
{cardVO.isDefault && (
{cardVO.isDefault && (
<div className={styles.operationContainer}>
<div className={styles.operationDefaultBadge}>
<svg
className={styles.operationDefaultBadgeIcon}
@@ -48,8 +51,8 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
{t('pipelines.defaultBadge')}
</div>
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -4,6 +4,7 @@ export interface IPipelineCardVO {
description: string;
lastUpdatedTimeAgo: string;
isDefault: boolean;
emoji?: string;
}
export class PipelineCardVO implements IPipelineCardVO {
@@ -12,6 +13,7 @@ export class PipelineCardVO implements IPipelineCardVO {
name: string;
lastUpdatedTimeAgo: string;
isDefault: boolean;
emoji?: string;
constructor(props: IPipelineCardVO) {
this.id = props.id;
@@ -19,5 +21,6 @@ export class PipelineCardVO implements IPipelineCardVO {
this.description = props.description;
this.lastUpdatedTimeAgo = props.lastUpdatedTimeAgo;
this.isDefault = props.isDefault;
this.emoji = props.emoji;
}
}

View File

@@ -4,7 +4,7 @@
background-color: #fff;
border-radius: 10px;
box-shadow: 0px 2px 2px 0 rgba(0, 0, 0, 0.2);
padding: 1.2rem;
padding: 1rem;
cursor: pointer;
display: flex;
flex-direction: row;
@@ -32,14 +32,41 @@
display: flex;
flex-direction: column;
justify-content: space-between;
gap: 0.4rem;
gap: 0.5rem;
min-width: 0;
}
.iconEmoji {
width: 3rem;
height: 3rem;
border-radius: 0.5rem;
background-color: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.75rem;
flex-shrink: 0;
}
:global(.dark) .iconEmoji {
background-color: #2a2a2d;
}
.iconBasicInfoContainer {
display: flex;
flex-direction: row;
gap: 0.75rem;
align-items: flex-start;
min-width: 0;
flex: 1;
}
.basicInfoNameContainer {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
flex: 1;
}
.basicInfoNameText {

View File

@@ -13,6 +13,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Input } from '@/components/ui/input';
import EmojiPicker from '@/components/ui/emoji-picker';
import {
Form,
FormControl,
@@ -62,6 +63,7 @@ export default function PipelineFormComponent({
description: z
.string()
.min(1, { message: t('pipelines.descriptionRequired') }),
emoji: z.string().optional(),
}),
ai: z.record(z.string(), z.any()),
trigger: z.record(z.string(), z.any()),
@@ -74,6 +76,7 @@ export default function PipelineFormComponent({
description: z
.string()
.min(1, { message: t('pipelines.descriptionRequired') }),
emoji: z.string().optional(),
}),
ai: z.record(z.string(), z.any()).optional(),
trigger: z.record(z.string(), z.any()).optional(),
@@ -105,7 +108,9 @@ export default function PipelineFormComponent({
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
basic: {},
basic: {
emoji: '⚙️',
},
ai: {},
trigger: {},
safety: {},
@@ -138,6 +143,7 @@ export default function PipelineFormComponent({
basic: {
name: resp.pipeline.name,
description: resp.pipeline.description,
emoji: resp.pipeline.emoji || '⚙️',
},
ai: resp.pipeline.config.ai,
trigger: resp.pipeline.config.trigger,
@@ -154,6 +160,7 @@ export default function PipelineFormComponent({
basic: {
name: '',
description: '',
emoji: '⚙️',
},
});
}
@@ -172,6 +179,7 @@ export default function PipelineFormComponent({
config: {},
description: values.basic.description,
name: values.basic.name,
emoji: values.basic.emoji,
};
httpClient
.createPipeline(pipeline)
@@ -199,6 +207,7 @@ export default function PipelineFormComponent({
description: values.basic.description,
// for_version: '',
name: values.basic.name,
emoji: values.basic.emoji,
// stages: [],
// updated_at: '',
// uuid: pipelineId || '',
@@ -399,22 +408,41 @@ export default function PipelineFormComponent({
>
{formLabel.name === 'basic' && (
<div className="space-y-6">
<FormField
control={form.control}
name="basic.name"
render={({ field }) => (
<FormItem>
<FormLabel>
{t('common.name')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* Name and Emoji in same row */}
<div className="flex gap-4 items-start">
<FormField
control={form.control}
name="basic.name"
render={({ field }) => (
<FormItem className="flex-1">
<FormLabel>
{t('common.name')}
<span className="text-red-500">*</span>
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="basic.emoji"
render={({ field }) => (
<FormItem>
<FormLabel>{t('common.icon')}</FormLabel>
<FormControl>
<EmojiPicker
value={field.value}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}

View File

@@ -69,6 +69,7 @@ export default function PluginConfigPage() {
description: pipeline.description,
id: pipeline.uuid ?? '',
name: pipeline.name,
emoji: pipeline.emoji,
isDefault: pipeline.is_default ?? false,
});
});

View File

@@ -1,7 +1,6 @@
'use client';
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
import { useSearchParams } from 'next/navigation';
import { Input } from '@/components/ui/input';
import {
Select,
@@ -11,14 +10,7 @@ import {
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Search,
Loader2,
Wrench,
AudioWaveform,
Hash,
Book,
} from 'lucide-react';
import { Search, Wrench, AudioWaveform, Hash, Book } from 'lucide-react';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
import { getCloudServiceClientSync } from '@/app/infra/http';
@@ -27,6 +19,9 @@ import { PluginV4 } from '@/app/infra/entities/plugin';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { TagsFilter } from './TagsFilter';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
interface SortOption {
value: string;
@@ -42,10 +37,12 @@ function MarketPageContent({
installPlugin: (plugin: PluginV4) => void;
}) {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState('');
const [componentFilter, setComponentFilter] = useState<string>('all');
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
const [tagNames, setTagNames] = useState<Record<string, string>>({});
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@@ -111,6 +108,7 @@ function MarketPageContent({
githubURL: plugin.repository,
version: plugin.latest_version,
components: plugin.components,
tags: plugin.tags || [],
});
}, []);
@@ -128,7 +126,7 @@ function MarketPageContent({
const filterValue =
componentFilter === 'all' ? undefined : componentFilter;
// Always use searchMarketplacePlugins to support component filtering
// Always use searchMarketplacePlugins to support component filtering and tags filtering
const response =
await getCloudServiceClientSync().searchMarketplacePlugins(
isSearch && searchQuery.trim() ? searchQuery.trim() : '',
@@ -137,6 +135,7 @@ function MarketPageContent({
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
);
const data: ApiRespMarketplacePlugins = response;
@@ -165,6 +164,7 @@ function MarketPageContent({
[
searchQuery,
componentFilter,
selectedTags,
pageSize,
transformToVO,
plugins.length,
@@ -175,8 +175,34 @@ function MarketPageContent({
// 初始加载
useEffect(() => {
fetchPlugins(1, false, true);
fetchAvailableTags();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 获取可用标签
const fetchAvailableTags = async () => {
try {
const response = await getCloudServiceClientSync().getAllTags();
const tags = response.tags || [];
setAvailableTags(tags);
// Build tag names map for all components to use
const nameMap: Record<string, string> = {};
tags.forEach((tag: PluginTag) => {
const displayName = {
en_US: tag.display_name.en_US || tag.tag,
zh_Hans: tag.display_name.zh_Hans || tag.tag,
zh_Hant: tag.display_name.zh_Hant,
ja_JP: tag.display_name.ja_JP,
};
nameMap[tag.tag] = extractI18nObject(displayName);
});
setTagNames(nameMap);
} catch (error) {
console.error('Failed to fetch tags:', error);
}
};
// 搜索功能
const handleSearch = useCallback(
(query: string) => {
@@ -227,16 +253,19 @@ function MarketPageContent({
fetchPlugins(1, !!searchQuery.trim(), true);
}, [sortOption, componentFilter]);
// 处理URL参数重定向到 LangBot Space
// Tags 筛选变化时重新搜索
useEffect(() => {
const author = searchParams.get('author');
const pluginName = searchParams.get('plugin');
if (author && pluginName) {
const detailUrl = `https://space.langbot.app/market/${author}/${pluginName}`;
window.open(detailUrl, '_blank');
if (!isLoading) {
setCurrentPage(1);
fetchPlugins(1, searchQuery.trim() !== '', true);
}
}, [searchParams]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedTags]);
// 处理 tags 变化
const handleTagsChange = useCallback((tags: string[]) => {
setSelectedTags(tags);
}, []);
// 处理安装插件
const handleInstallPlugin = useCallback(
@@ -342,8 +371,8 @@ function MarketPageContent({
<div className="h-full flex flex-col">
{/* Fixed header with search and sort controls */}
<div className="flex-shrink-0 space-y-4 px-3 sm:px-4 py-4 sm:py-6">
{/* Search box */}
<div className="flex items-center justify-center">
{/* Search box and Tags filter */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<div className="relative w-full max-w-2xl">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
@@ -362,6 +391,13 @@ function MarketPageContent({
className="pl-10 pr-4 text-sm sm:text-base"
/>
</div>
{/* Tags filter */}
<TagsFilter
availableTags={availableTags}
selectedTags={selectedTags}
onTagsChange={handleTagsChange}
/>
</div>
{/* Component filter and sort */}
@@ -460,8 +496,7 @@ function MarketPageContent({
>
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">{t('market.loading')}</span>
<LoadingSpinner text={t('market.loading')} />
</div>
) : plugins.length === 0 ? (
<div className="flex items-center justify-center py-12">
@@ -477,6 +512,7 @@ function MarketPageContent({
key={plugin.pluginId}
cardVO={plugin}
onInstall={handleInstallPlugin}
tagNames={tagNames}
/>
))}
</div>
@@ -484,8 +520,7 @@ function MarketPageContent({
{/* Loading more indicator */}
{isLoadingMore && (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="ml-2">{t('market.loadingMore')}</span>
<LoadingSpinner size="sm" text={t('market.loadingMore')} />
</div>
)}
@@ -522,8 +557,7 @@ export default function MarketPage({
fallback={
<div className="container mx-auto px-4 py-6">
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
<span className="ml-2">...</span>
<LoadingSpinner text="加载中..." />
</div>
</div>
}

View File

@@ -0,0 +1,117 @@
'use client';
import { useTranslation } from 'react-i18next';
import {
Select,
SelectContent,
SelectGroup,
SelectTrigger,
} from '@/components/ui/select';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Tag as TagIcon } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
interface TagsFilterProps {
availableTags: PluginTag[];
selectedTags: string[];
onTagsChange: (tags: string[]) => void;
}
export function TagsFilter({
availableTags,
selectedTags,
onTagsChange,
}: TagsFilterProps) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const handleTagToggle = (tag: string) => {
const newTags = selectedTags.includes(tag)
? selectedTags.filter((t) => t !== tag)
: [...selectedTags, tag];
onTagsChange(newTags);
};
const handleClearAll = () => {
onTagsChange([]);
};
const extractI18nObject = (obj: { zh_Hans?: string; en_US?: string }) => {
const lang = i18n.language || 'en_US';
return obj[lang as keyof typeof obj] || obj.zh_Hans || obj.en_US || '';
};
return (
<Select open={open} onOpenChange={setOpen}>
<SelectTrigger className="w-[140px]">
<div className="flex items-center gap-2 w-full">
<TagIcon className="h-4 w-4 flex-shrink-0" />
{selectedTags.length === 0 ? (
<span className="text-muted-foreground truncate text-sm">
{t('market.tags.filterByTags')}
</span>
) : (
<span className="text-sm truncate">
{selectedTags.length} {t('market.tags.selected')}
</span>
)}
</div>
</SelectTrigger>
<SelectContent className="w-[240px]">
<SelectGroup>
<div className="px-2 py-1.5 flex items-center justify-between border-b">
<span className="text-sm font-medium">
{t('market.tags.selectTags')}
</span>
{selectedTags.length > 0 && (
<Button
variant="ghost"
size="sm"
onClick={handleClearAll}
className="h-auto p-0 text-xs hover:bg-transparent hover:text-destructive"
>
{t('market.tags.clearAll')}
</Button>
)}
</div>
{availableTags.length === 0 ? (
<div className="px-2 py-6 text-center text-sm text-muted-foreground">
{t('market.tags.noTags')}
</div>
) : (
<div className="max-h-[300px] overflow-y-auto">
{availableTags.map((tag) => (
<div
key={tag.tag}
className="flex items-center space-x-2 px-2 py-2 hover:bg-accent cursor-pointer"
onClick={(e) => {
e.preventDefault();
handleTagToggle(tag.tag);
}}
>
<Checkbox
id={`tag-${tag.tag}`}
checked={selectedTags.includes(tag.tag)}
onClick={(e) => e.stopPropagation()}
onCheckedChange={() => handleTagToggle(tag.tag)}
/>
<Label
htmlFor={`tag-${tag.tag}`}
className="text-sm font-normal cursor-pointer flex-1"
onClick={(e) => e.preventDefault()}
>
{extractI18nObject(tag.display_name)}
</Label>
</div>
))}
</div>
)}
</SelectGroup>
</SelectContent>
</Select>
);
}

View File

@@ -15,9 +15,11 @@ import { Button } from '@/components/ui/button';
export default function PluginMarketCardComponent({
cardVO,
onInstall,
tagNames = {},
}: {
cardVO: PluginMarketCardVO;
onInstall?: (author: string, pluginName: string) => void;
tagNames?: Record<string, string>;
}) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
@@ -42,13 +44,6 @@ export default function PluginMarketCardComponent({
KnowledgeRetriever: <Book className="w-4 h-4" />,
};
const componentKindNameMap: Record<string, string> = {
Tool: t('plugins.componentName.Tool'),
EventListener: t('plugins.componentName.EventListener'),
Command: t('plugins.componentName.Command'),
KnowledgeRetriever: t('plugins.componentName.KnowledgeRetriever'),
};
return (
<div
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] transition-all duration-200 hover:scale-[1.005] dark:bg-[#1f1f22] relative"
@@ -97,24 +92,63 @@ export default function PluginMarketCardComponent({
</div>
</div>
{/* 下部分:下载量和组件列表 */}
<div className="w-full flex flex-row items-center justify-between gap-[0.3rem] sm:gap-[0.4rem] px-0 sm:px-[0.4rem] flex-shrink-0">
<div className="flex flex-row items-center justify-start gap-[0.3rem] sm:gap-[0.4rem]">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] font-medium whitespace-nowrap">
{cardVO.installCount.toLocaleString()}
{/* 下部分:下载量、标签和组件列表 */}
<div className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0">
<div className="flex flex-row items-center justify-start gap-2 flex-wrap">
{/* 下载数量 */}
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem]">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7,10 12,15 17,10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
<div className="text-xs sm:text-sm text-[#2563eb] dark:text-[#5b8def] font-medium whitespace-nowrap">
{cardVO.installCount?.toLocaleString() ?? '0'}
</div>
</div>
{/* Tags */}
{cardVO.tags && cardVO.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{cardVO.tags.slice(0, 2).map((tag) => (
<Badge
key={tag}
variant="secondary"
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center gap-1 flex-shrink-0"
>
<svg
className="w-2.5 h-2.5 flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
<span className="truncate">{tagNames[tag] || tag}</span>
</Badge>
))}
{cardVO.tags.length > 2 && (
<Badge
variant="outline"
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center flex-shrink-0"
>
+{cardVO.tags.length - 2}
</Badge>
)}
</div>
)}
</div>
{/* 组件列表 */}
@@ -127,10 +161,6 @@ export default function PluginMarketCardComponent({
className="flex items-center gap-1"
>
{kindIconMap[kind]}
{/* 响应式显示组件名称:在中等屏幕以上显示 */}
<span className="hidden md:inline">
{componentKindNameMap[kind]}
</span>
<span className="ml-1">{count}</span>
</Badge>
))}

View File

@@ -9,6 +9,7 @@ export interface IPluginMarketCardVO {
githubURL: string;
version: string;
components?: Record<string, number>;
tags?: string[];
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
@@ -22,6 +23,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
installCount: number;
version: string;
components?: Record<string, number>;
tags?: string[];
constructor(prop: IPluginMarketCardVO) {
this.description = prop.description;
@@ -34,5 +36,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.pluginId = prop.pluginId;
this.version = prop.version;
this.components = prop.components;
this.tags = prop.tags;
}
}

View File

@@ -78,6 +78,7 @@ export interface KnowledgeBase {
created_at?: string;
updated_at?: string;
top_k: number;
emoji?: string;
}
export interface ApiRespProviderEmbeddingModels {
@@ -110,6 +111,7 @@ export interface Pipeline {
is_default?: boolean;
created_at?: string;
updated_at?: string;
emoji?: string;
}
export interface ApiRespPlatformAdapters {
@@ -168,6 +170,7 @@ export interface KnowledgeBase {
top_k: number;
created_at?: string;
updated_at?: string;
emoji?: string;
}
export interface ExternalKnowledgeBase {
@@ -179,6 +182,7 @@ export interface ExternalKnowledgeBase {
plugin_name: string;
retriever_name: string;
retriever_config?: Record<string, unknown>;
emoji?: string;
}
export interface ApiRespExternalKnowledgeBases {
@@ -306,6 +310,7 @@ interface GetPipeline {
stages: string[];
updated_at: string;
uuid: string;
emoji?: string;
}
export interface GetPipelineResponseData {

View File

@@ -803,4 +803,150 @@ export class BackendClient extends BaseHttpClient {
}
return response.data.data;
}
// ============ Monitoring API ============
public getMonitoringData(params: {
botId?: string[];
pipelineId?: string[];
startTime?: string;
endTime?: string;
limit?: number;
}): Promise<{
overview: {
total_messages: number;
llm_calls: number;
embedding_calls: number;
model_calls: number;
success_rate: number;
active_sessions: number;
};
messages: Array<{
id: string;
timestamp: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
message_content: string;
session_id: string;
status: string;
level: string;
platform?: string;
user_id?: string;
runner_name?: string;
variables?: string;
}>;
llmCalls: Array<{
id: string;
timestamp: string;
model_name: string;
input_tokens: number;
output_tokens: number;
total_tokens: number;
duration: number;
cost?: number;
status: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
error_message?: string;
message_id?: string;
}>;
embeddingCalls: Array<{
id: string;
timestamp: string;
model_name: string;
prompt_tokens: number;
total_tokens: number;
duration: number;
input_count: number;
status: string;
error_message?: string;
knowledge_base_id?: string;
query_text?: string;
session_id?: string;
message_id?: string;
call_type?: string;
}>;
sessions: Array<{
session_id: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
message_count: number;
last_activity: string;
start_time: string;
platform?: string;
user_id?: string;
}>;
errors: Array<{
id: string;
timestamp: string;
error_type: string;
error_message: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
session_id?: string;
stack_trace?: string;
message_id?: string;
}>;
totalCount: {
messages: number;
llmCalls: number;
embeddingCalls: number;
sessions: number;
errors: number;
};
}> {
const queryParams = new URLSearchParams();
if (params.botId) {
params.botId.forEach((id) => queryParams.append('botId', id));
}
if (params.pipelineId) {
params.pipelineId.forEach((id) => queryParams.append('pipelineId', id));
}
if (params.startTime) {
queryParams.append('startTime', params.startTime);
}
if (params.endTime) {
queryParams.append('endTime', params.endTime);
}
if (params.limit) {
queryParams.append('limit', params.limit.toString());
}
return this.get(`/api/v1/monitoring/data?${queryParams.toString()}`);
}
public getMonitoringOverview(params: {
botId?: string[];
pipelineId?: string[];
startTime?: string;
endTime?: string;
}): Promise<{
total_messages: number;
llm_calls: number;
success_rate: number;
active_sessions: number;
}> {
const queryParams = new URLSearchParams();
if (params.botId) {
params.botId.forEach((id) => queryParams.append('botId', id));
}
if (params.pipelineId) {
params.pipelineId.forEach((id) => queryParams.append('pipelineId', id));
}
if (params.startTime) {
queryParams.append('startTime', params.startTime);
}
if (params.endTime) {
queryParams.append('endTime', params.endTime);
}
return this.get(`/api/v1/monitoring/overview?${queryParams.toString()}`);
}
}

View File

@@ -35,6 +35,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_by?: string,
sort_order?: string,
component_filter?: string,
tags_filter?: string[],
): Promise<ApiRespMarketplacePlugins> {
return this.post<ApiRespMarketplacePlugins>(
'/api/v1/marketplace/plugins/search',
@@ -45,6 +46,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_by,
sort_order,
component_filter,
tags_filter,
},
);
}
@@ -92,6 +94,20 @@ export class CloudServiceClient extends BaseHttpClient {
public getLangBotReleases(): Promise<GitHubRelease[]> {
return this.get<GitHubRelease[]>('/api/v1/dist/info/releases');
}
public getAllTags(): Promise<{ tags: PluginTag[] }> {
return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags');
}
}
export interface PluginTag {
tag: string;
display_name: {
zh_Hans?: string;
en_US?: string;
zh_Hant?: string;
ja_JP?: string;
};
}
export interface GitHubRelease {

View File

@@ -12,6 +12,13 @@ export let systemInfo: ApiRespSystemInfo = {
disable_models_service: false,
};
// 用户信息
export let userInfo: {
user: string;
account_type: 'local' | 'space';
has_password: boolean;
} | null = null;
/**
* 获取基础 URL
*/
@@ -24,6 +31,8 @@ const getBaseURL = (): string => {
// 创建后端客户端实例
export const backendClient = new BackendClient(getBaseURL());
// 为了兼容性,也导出为 httpClient
export const httpClient = backendClient;
// 创建云服务客户端实例(初始化时使用默认 URL
export const cloudServiceClient = new CloudServiceClient(
@@ -82,6 +91,27 @@ export const initializeSystemInfo = async (): Promise<void> => {
}
};
/**
* 初始化用户信息
* 应该在用户登录后调用此方法
*/
export const initializeUserInfo = async (): Promise<void> => {
try {
userInfo = await backendClient.getUserInfo();
} catch (error) {
console.error('Failed to initialize user info:', error);
userInfo = null;
}
};
/**
* 清除用户信息
* 应该在用户登出时调用此方法
*/
export const clearUserInfo = (): void => {
userInfo = null;
};
// 导出类型,以便其他地方使用
export type { ResponseData, RequestConfig } from './BaseHttpClient';
export { BaseHttpClient } from './BaseHttpClient';

View File

@@ -7,7 +7,8 @@ import { ThemeProvider } from '@/components/providers/theme-provider';
export const metadata: Metadata = {
title: 'LangBot',
description: 'LangBot 是大模型原生即时通信机器人平台',
description:
'Production-grade platform for building agentic IM bots, integrated with Telegram, Slack, Discord, WeChat, QQ, etc.',
};
export default function RootLayout({

View File

@@ -21,7 +21,7 @@ import {
FormMessage,
} from '@/components/ui/form';
import { useEffect, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { httpClient, initializeUserInfo } from '@/app/infra/http';
import { useRouter } from 'next/navigation';
import { Mail, Lock, Loader2 } from 'lucide-react';
import langbotIcon from '@/app/assets/langbot-logo.webp';
@@ -29,6 +29,7 @@ import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import Link from 'next/link';
import { ThemeToggle } from '@/components/ui/theme-toggle';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
const formSchema = (t: (key: string) => string) =>
z.object({
@@ -95,9 +96,10 @@ export default function Login() {
function handleLogin(username: string, password: string) {
httpClient
.authUser(username, password)
.then((res) => {
.then(async (res) => {
localStorage.setItem('token', res.token);
localStorage.setItem('userEmail', username);
await initializeUserInfo();
router.push('/home');
toast.success(t('common.loginSuccess'));
})
@@ -122,7 +124,7 @@ export default function Login() {
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-neutral-900">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<LoadingSpinner />
</div>
);
}

View File

@@ -0,0 +1,253 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
interface EmojiPickerProps {
value?: string;
onChange: (emoji: string) => void;
disabled?: boolean;
}
// 扩展的emoji分类
const EMOJI_CATEGORIES = {
common: [
'⚙️',
'📚',
'🔗',
'📁',
'💡',
'🎯',
'✨',
'🚀',
'📝',
'🔧',
'⚡',
'🔥',
'💎',
'🎨',
'🎭',
],
objects: [
'📦',
'📂',
'📋',
'📌',
'🔖',
'💼',
'🗂️',
'📮',
'🗃️',
'📊',
'📈',
'📉',
'🗄️',
'📇',
'🗳️',
],
symbols: [
'🔴',
'🟠',
'🟡',
'🟢',
'🔵',
'🟣',
'⚪',
'⚫',
'🟤',
'🔺',
'🔻',
'🔶',
'🔷',
'🔸',
'🔹',
],
nature: [
'🌟',
'⭐',
'🌈',
'💧',
'🌍',
'🌙',
'☀️',
'🌱',
'🌲',
'🌳',
'🌴',
'🌵',
'🌾',
'🍀',
'🌻',
],
faces: [
'😀',
'😊',
'🤔',
'😎',
'🤖',
'👾',
'💬',
'💭',
'❤️',
'⚠️',
'✅',
'❌',
'🎉',
'🎊',
'🎈',
],
tech: [
'💻',
'📱',
'⌨️',
'🖥️',
'🖱️',
'💾',
'💿',
'📀',
'🔌',
'🔋',
'📡',
'🛰️',
'🖨️',
'🖲️',
'💽',
],
science: [
'🔬',
'🔭',
'⚗️',
'🧪',
'🧬',
'🧫',
'🩺',
'💊',
'💉',
'🌡️',
'🧲',
'⚛️',
'🧬',
'🦠',
'🧫',
],
business: [
'💼',
'📊',
'📈',
'💰',
'💵',
'💴',
'💶',
'💷',
'💳',
'💸',
'📉',
'💹',
'🏦',
'🏢',
'🏭',
],
};
const CATEGORY_LABELS: { [key: string]: string } = {
common: '常用',
objects: '物品',
symbols: '符号',
nature: '自然',
faces: '表情',
tech: '科技',
science: '科学',
business: '商业',
};
// 每个分类的代表性 emoji用于分页按钮
const CATEGORY_ICONS: { [key: string]: string } = {
common: '⭐',
objects: '📦',
symbols: '🔴',
nature: '🌟',
faces: '😀',
tech: '💻',
science: '🔬',
business: '💼',
};
export default function EmojiPicker({
value,
onChange,
disabled,
}: EmojiPickerProps) {
const [open, setOpen] = useState(false);
const [activeCategory, setActiveCategory] = useState<string>('common');
const handleEmojiSelect = (emoji: string) => {
onChange(emoji);
setOpen(false);
};
const currentEmojis =
EMOJI_CATEGORIES[activeCategory as keyof typeof EMOJI_CATEGORIES];
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
disabled={disabled}
className="w-16 h-16 text-3xl p-0 hover:bg-gray-100 dark:hover:bg-gray-800"
type="button"
>
{value || '😀'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-4" align="start">
<div className="space-y-3">
{/* 分类标题 */}
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300">
{CATEGORY_LABELS[activeCategory]}
</h3>
{/* Emoji 网格 */}
<div className="grid grid-cols-6 gap-1">
{currentEmojis.map((emoji, index) => (
<button
key={`${activeCategory}-${index}`}
type="button"
onClick={() => handleEmojiSelect(emoji)}
className={`w-10 h-10 text-xl rounded hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors flex items-center justify-center ${
value === emoji ? 'bg-gray-200 dark:bg-gray-700' : ''
}`}
>
{emoji}
</button>
))}
</div>
{/* 分类切换按钮 */}
<div className="pt-2 border-t border-gray-200 dark:border-gray-700">
<div className="flex justify-center gap-1">
{Object.keys(EMOJI_CATEGORIES).map((category) => (
<button
key={category}
type="button"
onClick={() => setActiveCategory(category)}
className={`w-7 h-7 text-base rounded transition-colors flex items-center justify-center ${
activeCategory === category
? 'bg-gray-200 dark:bg-gray-700'
: 'hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
title={CATEGORY_LABELS[category]}
>
{CATEGORY_ICONS[category]}
</button>
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,83 @@
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface LoadingSpinnerProps {
/**
* Size variant of the spinner
* @default 'default'
*/
size?: 'sm' | 'default' | 'lg';
/**
* Additional CSS classes
*/
className?: string;
/**
* Loading text to display below the spinner
*/
text?: string;
/**
* Whether to display as full page overlay
* @default false
*/
fullPage?: boolean;
}
const sizeMap = {
sm: 'h-4 w-4',
default: 'h-8 w-8',
lg: 'h-12 w-12',
};
const textSizeMap = {
sm: 'text-xs',
default: 'text-sm',
lg: 'text-base',
};
export function LoadingSpinner({
size = 'default',
className,
text = '加载中...',
fullPage = false,
}: LoadingSpinnerProps) {
const spinner = (
<div className="flex flex-col items-center gap-4">
<Loader2
className={cn('animate-spin text-primary', sizeMap[size], className)}
/>
{text && (
<p className={cn('text-muted-foreground', textSizeMap[size])}>{text}</p>
)}
</div>
);
if (fullPage) {
return (
<div className="fixed inset-0 flex items-center justify-center bg-background">
{spinner}
</div>
);
}
return spinner;
}
/**
* Full page loading component for use in page.tsx or layout.tsx
*/
export function LoadingPage({ text }: { text?: string }) {
return <LoadingSpinner fullPage text={text} />;
}
/**
* Inline loading component for use within components
*/
export function LoadingInline({
size,
text,
}: {
size?: 'sm' | 'default' | 'lg';
text?: string;
}) {
return <LoadingSpinner size={size} text={text} />;
}

View File

@@ -37,6 +37,7 @@ const enUS = {
enable: 'Enable',
name: 'Name',
description: 'Description',
icon: 'Icon',
close: 'Close',
deleteSuccess: 'Deleted successfully',
deleteError: 'Delete failed: ',
@@ -275,6 +276,10 @@ const enUS = {
allLevels: 'All Levels',
selectLevel: 'Select Level',
levelsSelected: 'levels selected',
viewDetailedLogs: 'View Detailed Logs',
viewDetails: 'Details',
collapse: 'Collapse',
imagesAttached: 'image(s) attached',
},
plugins: {
title: 'Extensions',
@@ -441,7 +446,7 @@ const enUS = {
downloadFailed: 'Download failed',
noReadme: 'This plugin does not provide README documentation',
description: 'Description',
tags: 'Tags',
tagLabel: 'Tags',
submissionTitle: 'You have a plugin submission under review: {{name}}',
submissionPending: 'Your plugin submission is under review: {{name}}',
submissionApproved: 'Your plugin submission has been approved: {{name}}',
@@ -457,6 +462,13 @@ const enUS = {
allComponents: 'All Components',
requestPlugin: 'Request Plugin',
viewDetails: 'View Details',
tags: {
filterByTags: 'Filter by Tags',
selected: 'selected',
selectTags: 'Select Tags',
clearAll: 'Clear All',
noTags: 'No tags available',
},
},
mcp: {
title: 'MCP',
@@ -632,6 +644,12 @@ const enUS = {
showMarkdown: 'Show Markdown',
showRaw: 'Show Raw',
},
monitoring: {
title: 'Monitoring',
description:
'View execution logs and errors for this pipeline (last 24 hours)',
detailedLogs: 'Detailed Logs',
},
},
knowledge: {
title: 'Knowledge',
@@ -801,6 +819,140 @@ const enUS = {
spaceEmailMismatch:
'Space login email does not match the local account email',
},
monitoring: {
title: 'Monitoring',
description: 'Monitor bot activities, LLM calls, and system performance',
overview: 'Overview',
totalMessages: 'Total Messages',
llmCallsCount: 'LLM Calls',
modelCallsCount: 'Model Calls',
successRate: 'Success Rate',
activeSessions: 'Active Sessions',
last24Hours: 'Last 24 hours',
filters: {
title: 'Filters',
bot: 'Bot',
pipeline: 'Pipeline',
allBots: 'All Bots',
selectBot: 'Select Bot',
allPipelines: 'All Pipelines',
selectPipeline: 'Select Pipeline',
loading: 'Loading...',
timeRange: 'Time Range',
customRange: 'Custom Range',
from: 'From',
to: 'To',
apply: 'Apply',
reset: 'Reset Filters',
lastHour: 'Last 1 hour',
last6Hours: 'Last 6 hours',
last24Hours: 'Last 24 hours',
last7Days: 'Last 7 days',
last30Days: 'Last 30 days',
},
tabs: {
messages: 'Message Records',
llmCalls: 'LLM Calls',
embeddingCalls: 'Embedding Calls',
modelCalls: 'Model Calls',
sessions: 'Session Analysis',
errors: 'Error Logs',
},
messageList: {
timestamp: 'Timestamp',
bot: 'Bot',
pipeline: 'Pipeline',
message: 'Message',
sessionId: 'Session ID',
status: 'Status',
actions: 'Actions',
viewDetails: 'View Details',
copyId: 'Copy ID',
noMessages: 'No messages found',
noMessagesDescription: 'Try adjusting your filters or check back later',
loading: 'Loading messages...',
loadMore: 'Load More',
autoRefresh: 'Auto Refresh',
platform: 'Role',
user: 'User',
level: 'Level',
runner: 'Runner',
viewConversation: 'View Conversation',
},
llmCalls: {
title: 'LLM Calls',
model: 'Model',
tokens: 'Tokens',
duration: 'Duration',
cost: 'Cost',
noData: 'No LLM calls found',
inputTokens: 'Input Tokens',
outputTokens: 'Output Tokens',
totalTokens: 'Total Tokens',
avgDuration: 'Avg Duration',
calls: 'Calls',
},
embeddingCalls: {
title: 'Embedding Calls',
model: 'Model',
tokens: 'Tokens',
duration: 'Duration',
noData: 'No embedding calls found',
promptTokens: 'Prompt Tokens',
totalTokens: 'Total Tokens',
inputCount: 'Input Count',
knowledgeBase: 'Knowledge Base',
queryText: 'Query',
},
modelCalls: {
title: 'Model Calls',
llmModel: 'LLM',
embeddingModel: 'Embedding',
embeddingCall: 'Embedding',
retrieveCall: 'Retrieve',
noData: 'No model calls found',
},
sessions: {
sessionId: 'Session ID',
messageCount: 'Messages',
duration: 'Duration',
lastActivity: 'Last Activity',
noSessions: 'No sessions found',
startTime: 'Start Time',
messageStats: 'Message Statistics',
totalMessages: 'Total Messages',
successMessages: 'Successful',
errorMessages: 'Failed',
llmStats: 'LLM Statistics',
noData: 'Session not found',
},
errors: {
title: 'Errors',
errorType: 'Error Type',
errorMessage: 'Error Message',
occurredAt: 'Occurred At',
noErrors: 'No errors found',
stackTrace: 'Stack Trace',
},
queries: {
title: 'Queries',
},
messageDetails: {
noData: 'No LLM calls or errors for this query',
},
queryVariables: {
title: 'Query Variables',
},
trafficChart: {
title: 'Traffic Overview',
messages: 'Messages',
llmCalls: 'LLM Calls',
noData: 'No traffic data available',
},
viewMonitoring: 'View Monitoring',
refreshData: 'Refresh Data',
exportData: 'Export Data',
},
};
export default enUS;

View File

@@ -38,6 +38,7 @@ const jaJP = {
enable: '有効にする',
name: '名前',
description: '説明',
icon: 'アイコン',
close: '閉じる',
deleteSuccess: '削除に成功しました',
deleteError: '削除に失敗しました:',
@@ -446,7 +447,7 @@ const jaJP = {
downloadFailed: 'ダウンロード失敗',
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
description: '説明',
tags: 'タグ',
tagLabel: 'タグ',
submissionTitle: 'プラグインの提出が審査中です: {{name}}',
submissionPending: 'プラグインの提出が審査中です: {{name}}',
submissionApproved: 'プラグインの提出が承認されました: {{name}}',
@@ -461,6 +462,13 @@ const jaJP = {
filterByComponent: 'コンポーネント',
allComponents: '全部コンポーネント',
requestPlugin: 'プラグインをリクエスト',
tags: {
filterByTags: 'タグで絞り込み',
selected: '選択済み',
selectTags: 'タグを選択',
clearAll: 'クリア',
noTags: 'タグがありません',
},
viewDetails: '詳細を表示',
},
mcp: {
@@ -639,6 +647,11 @@ const jaJP = {
showMarkdown: 'Markdownで表示',
showRaw: '原文で表示',
},
monitoring: {
title: 'モニタリング',
description: 'このパイプラインの実行ログとエラー情報を表示過去24時間',
detailedLogs: '詳細ログ',
},
},
knowledge: {
title: '知識ベース',
@@ -810,6 +823,123 @@ const jaJP = {
spaceEmailMismatch:
'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません',
},
monitoring: {
title: 'モニタリング',
description:
'ボットアクティビティ、LLM呼び出し、システムパフォーマンスを監視',
overview: '概要',
totalMessages: '総メッセージ数',
llmCallsCount: 'LLM呼び出し',
modelCallsCount: 'モデル呼び出し',
successRate: '成功率',
activeSessions: 'アクティブセッション',
last24Hours: '過去24時間',
filters: {
title: 'フィルター',
bot: 'ボット',
pipeline: 'パイプライン',
allBots: 'すべてのボット',
selectBot: 'ボットを選択',
allPipelines: 'すべてのパイプライン',
selectPipeline: 'パイプラインを選択',
loading: '読み込み中...',
timeRange: '時間範囲',
customRange: 'カスタム範囲',
from: '開始',
to: '終了',
apply: '適用',
reset: 'フィルターをリセット',
lastHour: '過去1時間',
last6Hours: '過去6時間',
last24Hours: '過去24時間',
last7Days: '過去7日間',
last30Days: '過去30日間',
},
tabs: {
messages: 'メッセージ記録',
llmCalls: 'LLM呼び出し',
embeddingCalls: 'Embedding呼び出し',
modelCalls: 'モデル呼び出し',
sessions: 'セッション分析',
errors: 'エラーログ',
},
messageList: {
timestamp: 'タイムスタンプ',
bot: 'ボット',
pipeline: 'パイプライン',
message: 'メッセージ',
sessionId: 'セッションID',
status: 'ステータス',
actions: 'アクション',
viewDetails: '詳細を表示',
copyId: 'IDをコピー',
noMessages: 'メッセージが見つかりません',
noMessagesDescription: 'フィルターを調整するか、後で確認してください',
loading: 'メッセージを読み込んでいます...',
loadMore: 'もっと読み込む',
autoRefresh: '自動更新',
platform: 'プラットフォーム',
user: 'ユーザー',
level: 'レベル',
runner: 'ランナー',
viewConversation: '会話詳細を表示',
},
llmCalls: {
model: 'モデル',
tokens: 'トークン数',
duration: '期間',
cost: 'コスト',
noData: 'LLM呼び出し記録が見つかりません',
inputTokens: '入力トークン',
outputTokens: '出力トークン',
totalTokens: '合計トークン数',
},
embeddingCalls: {
title: 'Embedding呼び出し',
model: 'モデル',
tokens: 'トークン数',
duration: '期間',
noData: 'Embedding呼び出し記録が見つかりません',
promptTokens: '入力トークン',
totalTokens: '合計トークン数',
inputCount: '入力数',
knowledgeBase: 'ナレッジベース',
queryText: 'クエリ',
},
modelCalls: {
title: 'モデル呼び出し',
llmModel: '対話モデル',
embeddingModel: '埋め込みモデル',
embeddingCall: '埋め込み呼び出し',
retrieveCall: '検索呼び出し',
noData: 'モデル呼び出し記録が見つかりません',
},
sessions: {
sessionId: 'セッションID',
messageCount: 'メッセージ数',
duration: '期間',
lastActivity: '最終アクティビティ',
noSessions: 'セッションが見つかりません',
startTime: '開始時刻',
},
errors: {
errorType: 'エラータイプ',
errorMessage: 'エラーメッセージ',
occurredAt: '発生時刻',
noErrors: 'エラーが見つかりません',
stackTrace: 'スタックトレース',
title: 'エラー',
},
messageDetails: {
noData: 'このクエリにはLLM呼び出しやエラーがありません',
},
queryVariables: {
title: 'クエリ変数',
},
viewMonitoring: 'モニタリングを表示',
refreshData: 'データを更新',
exportData: 'データをエクスポート',
},
};
export default jaJP;

View File

@@ -37,6 +37,7 @@ const zhHans = {
enable: '是否启用',
name: '名称',
description: '描述',
icon: '图标',
close: '关闭',
deleteSuccess: '删除成功',
deleteError: '删除失败:',
@@ -264,6 +265,10 @@ const zhHans = {
allLevels: '全部级别',
selectLevel: '选择级别',
levelsSelected: '个级别已选',
viewDetailedLogs: '查看详细日志',
viewDetails: '详情',
collapse: '收起',
imagesAttached: '张图片',
},
plugins: {
title: '插件扩展',
@@ -420,7 +425,7 @@ const zhHans = {
downloadFailed: '下载失败',
noReadme: '该插件没有提供 README 文档',
description: '描述',
tags: '标签',
tagLabel: '标签',
submissionTitle: '您有插件提交正在审核中: {{name}}',
submissionApproved: '您的插件提交已通过审核: {{name}}',
submissionRejected: '您的插件提交已被拒绝: {{name}}',
@@ -434,6 +439,13 @@ const zhHans = {
filterByComponent: '组件',
allComponents: '全部组件',
requestPlugin: '请求插件',
tags: {
filterByTags: '按标签筛选',
selected: '已选',
selectTags: '选择标签',
clearAll: '清空',
noTags: '暂无标签',
},
viewDetails: '查看详情',
},
mcp: {
@@ -608,6 +620,11 @@ const zhHans = {
showMarkdown: '渲染',
showRaw: '原文',
},
monitoring: {
title: '监控日志',
description: '查看此流水线的运行记录和错误信息最近24小时',
detailedLogs: '详细日志',
},
},
knowledge: {
title: '知识库',
@@ -728,7 +745,7 @@ const zhHans = {
},
llm: {
llmModels: '对话模型',
description: '管理 LLM 模型用于对话消息生成',
description: '管理 LLM 模型,用于对话消息生成',
extraParametersDescription:
'将在请求时附加到请求体中,如 max_tokens, temperature, top_p 等',
},
@@ -762,6 +779,140 @@ const zhHans = {
setPasswordHint: '设置密码后可使用邮箱密码登录',
spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配',
},
monitoring: {
title: '日志监控',
description: '查看机器人活动、LLM调用和系统性能',
overview: '概览',
totalMessages: '总消息数',
llmCallsCount: 'LLM调用',
modelCallsCount: '模型调用',
successRate: '成功率',
activeSessions: '活跃会话',
last24Hours: '最近24小时',
filters: {
title: '筛选',
bot: '机器人',
pipeline: '流水线',
allBots: '全部机器人',
selectBot: '选择机器人',
allPipelines: '全部流水线',
selectPipeline: '选择流水线',
loading: '加载中...',
timeRange: '时间范围',
customRange: '自定义范围',
from: '从',
to: '到',
apply: '应用',
reset: '重置筛选',
lastHour: '最近1小时',
last6Hours: '最近6小时',
last24Hours: '最近24小时',
last7Days: '最近7天',
last30Days: '最近30天',
},
tabs: {
messages: '消息记录',
llmCalls: 'LLM调用',
embeddingCalls: 'Embedding调用',
modelCalls: '模型调用',
sessions: '会话分析',
errors: '错误日志',
},
messageList: {
timestamp: '时间戳',
bot: '机器人',
pipeline: '流水线',
message: '消息',
sessionId: '会话ID',
status: '状态',
actions: '操作',
viewDetails: '查看详情',
copyId: '复制ID',
noMessages: '未找到消息',
noMessagesDescription: '尝试调整筛选条件或稍后查看',
loading: '加载消息中...',
loadMore: '加载更多',
autoRefresh: '自动刷新',
platform: '角色',
user: '用户',
level: '级别',
runner: '执行器',
viewConversation: '显示对话详情',
},
llmCalls: {
title: 'LLM调用',
model: '模型',
tokens: 'Token数',
duration: '持续时间',
cost: '成本',
noData: '未找到LLM调用记录',
inputTokens: '输入Token',
outputTokens: '输出Token',
totalTokens: '总Token数',
avgDuration: '平均耗时',
calls: '调用次数',
},
embeddingCalls: {
title: 'Embedding调用',
model: '模型',
tokens: 'Token数',
duration: '持续时间',
noData: '未找到Embedding调用记录',
promptTokens: '输入Token',
totalTokens: '总Token数',
inputCount: '输入数量',
knowledgeBase: '知识库',
queryText: '查询文本',
},
modelCalls: {
title: '模型调用',
llmModel: '对话模型',
embeddingModel: '嵌入模型',
embeddingCall: '嵌入调用',
retrieveCall: '检索调用',
noData: '未找到模型调用记录',
},
sessions: {
sessionId: '会话ID',
messageCount: '消息数',
duration: '持续时间',
lastActivity: '最后活动',
noSessions: '未找到会话',
startTime: '开始时间',
messageStats: '消息统计',
totalMessages: '总消息数',
successMessages: '成功',
errorMessages: '失败',
llmStats: 'LLM统计',
noData: '会话未找到',
},
errors: {
title: '错误',
errorType: '错误类型',
errorMessage: '错误消息',
occurredAt: '发生时间',
noErrors: '未找到错误',
stackTrace: '堆栈追踪',
},
queries: {
title: '查询记录',
},
messageDetails: {
noData: '此查询没有LLM调用或错误记录',
},
queryVariables: {
title: '查询变量',
},
trafficChart: {
title: '流量概览',
messages: '消息数',
llmCalls: 'LLM调用',
noData: '暂无流量数据',
},
viewMonitoring: '查看日志监控',
refreshData: '刷新数据',
exportData: '导出数据',
},
};
export default zhHans;

View File

@@ -37,6 +37,7 @@ const zhHant = {
enable: '是否啟用',
name: '名稱',
description: '描述',
icon: '圖標',
close: '關閉',
deleteSuccess: '刪除成功',
deleteError: '刪除失敗:',
@@ -417,7 +418,7 @@ const zhHant = {
downloadFailed: '下載失敗',
noReadme: '該插件沒有提供 README 文件',
description: '描述',
tags: '標籤',
tagLabel: '標籤',
submissionTitle: '您有插件提交正在審核中: {{name}}',
submissionApproved: '您的插件提交已通過審核: {{name}}',
submissionRejected: '您的插件提交已被拒絕: {{name}}',
@@ -431,6 +432,13 @@ const zhHant = {
filterByComponent: '組件',
allComponents: '全部組件',
requestPlugin: '請求插件',
tags: {
filterByTags: '按標籤篩選',
selected: '已選',
selectTags: '選擇標籤',
clearAll: '清空',
noTags: '暫無標籤',
},
viewDetails: '查看詳情',
},
mcp: {
@@ -605,6 +613,11 @@ const zhHant = {
showMarkdown: '渲染',
showRaw: '原文',
},
monitoring: {
title: '監控日誌',
description: '檢視此流程線的執行記錄和錯誤資訊最近24小時',
detailedLogs: '詳細日誌',
},
},
knowledge: {
title: '知識庫',
@@ -759,6 +772,122 @@ const zhHant = {
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配',
},
monitoring: {
title: '日誌監控',
description: '監控機器人活動、LLM調用和系統效能',
overview: '概覽',
totalMessages: '總訊息數',
llmCallsCount: 'LLM調用',
modelCallsCount: '模型調用',
successRate: '成功率',
activeSessions: '活躍會話',
last24Hours: '最近24小時',
filters: {
title: '篩選',
bot: '機器人',
pipeline: '流水線',
allBots: '全部機器人',
selectBot: '選擇機器人',
allPipelines: '全部流水線',
selectPipeline: '選擇流水線',
loading: '載入中...',
timeRange: '時間範圍',
customRange: '自訂範圍',
from: '從',
to: '到',
apply: '套用',
reset: '重置篩選',
lastHour: '最近1小時',
last6Hours: '最近6小時',
last24Hours: '最近24小時',
last7Days: '最近7天',
last30Days: '最近30天',
},
tabs: {
messages: '訊息記錄',
llmCalls: 'LLM調用',
embeddingCalls: 'Embedding調用',
modelCalls: '模型調用',
sessions: '會話分析',
errors: '錯誤日誌',
},
messageList: {
timestamp: '時間戳記',
bot: '機器人',
pipeline: '流水線',
message: '訊息',
sessionId: '會話ID',
status: '狀態',
actions: '操作',
viewDetails: '查看詳情',
copyId: '複製ID',
noMessages: '未找到訊息',
noMessagesDescription: '嘗試調整篩選條件或稍後查看',
loading: '載入訊息中...',
loadMore: '載入更多',
autoRefresh: '自動重新整理',
platform: '平台',
user: '使用者',
level: '級別',
runner: '執行器',
viewConversation: '顯示對話詳情',
},
llmCalls: {
model: '模型',
tokens: '代幣數',
duration: '持續時間',
cost: '成本',
noData: '未找到LLM調用記錄',
inputTokens: '輸入代幣',
outputTokens: '輸出代幣',
totalTokens: '總代幣數',
},
embeddingCalls: {
title: 'Embedding調用',
model: '模型',
tokens: '代幣數',
duration: '持續時間',
noData: '未找到Embedding調用記錄',
promptTokens: '輸入代幣',
totalTokens: '總代幣數',
inputCount: '輸入數量',
knowledgeBase: '知識庫',
queryText: '查詢文字',
},
modelCalls: {
title: '模型調用',
llmModel: '對話模型',
embeddingModel: '嵌入模型',
embeddingCall: '嵌入調用',
retrieveCall: '檢索調用',
noData: '未找到模型調用記錄',
},
sessions: {
sessionId: '會話ID',
messageCount: '訊息數',
duration: '持續時間',
lastActivity: '最後活動',
noSessions: '未找到會話',
startTime: '開始時間',
},
errors: {
errorType: '錯誤類型',
errorMessage: '錯誤訊息',
occurredAt: '發生時間',
noErrors: '未找到錯誤',
stackTrace: '堆疊追蹤',
title: '錯誤',
},
messageDetails: {
noData: '此查詢沒有LLM調用或錯誤記錄',
},
queryVariables: {
title: '查詢變數',
},
viewMonitoring: '查看日誌監控',
refreshData: '重新整理資料',
exportData: '匯出資料',
},
};
export default zhHant;

View File

@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,19 @@
}
],
"paths": {
"@/*": ["./src/*"]
"@/*": [
"./src/*"
]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}