Compare commits

..

38 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
1e0275ee08 Improve code clarity and add documentation about in-place modification
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2026-01-30 15:53:40 +00:00
copilot-swe-agent[bot]
3fa7389ba9 Add defensive checks for thought_signature modification
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2026-01-30 15:49:25 +00:00
copilot-swe-agent[bot]
84a3b1f465 Add thought_signature to Gemini function calls for API compatibility
Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2026-01-30 15:47:56 +00:00
copilot-swe-agent[bot]
377d011da2 Initial plan 2026-01-30 15:44:33 +00:00
Junyan Qin
e0d72969e3 chore(deps): update langbot-plugin version to 0.2.5 in pyproject.toml 2026-01-30 17:31:21 +08:00
Junyan Qin
a65b7ad413 chore(deps): update pyseekdb version to 1.0.0b7 in pyproject.toml 2026-01-30 13:39:36 +08:00
Junyan Qin
45df44e01b chore: update uv.lock 2026-01-30 12:42:21 +08:00
Junyan Qin
d8addb105a chore: update .gitignore and add uv.lock for dependency management 2026-01-30 12:32:39 +08:00
Junyan Qin
f17ccad665 chore: update TypeScript configuration for improved compatibility and structure 2026-01-30 12:15:19 +08:00
Junyan Qin
120ceb0b55 chore: update linting configuration to use eslint directly 2026-01-30 12:03:43 +08:00
dependabot[bot]
8a6f80a181 chore(deps): bump lodash from 4.17.21 to 4.17.23 in /web (#1944)
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-30 11:25:16 +08:00
dependabot[bot]
b19e468668 chore(deps): bump next from 15.5.9 to 16.1.5 in /web (#1943)
Bumps [next](https://github.com/vercel/next.js) from 15.5.9 to 16.1.5.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v15.5.9...v16.1.5)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.1.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-30 11:20:08 +08:00
Junyan Qin
aeac79e1b3 feat: add tag filtering functionality to Plugin Market
- Introduced TagsFilter component for selecting and filtering plugins by tags.
- Updated PluginMarketComponent to handle tag selection and display.
- Enhanced PluginMarketCardComponent to show selected tags.
- Modified CloudServiceClient to fetch available tags from the API.
- Updated localization files to support new tag-related strings.
2026-01-29 16:08:05 +08:00
Junyan Qin
b89a240250 feat: implement LoadingSpinner component and replace existing loaders across the application 2026-01-29 15:24:23 +08:00
Junyan Qin
13f42857f5 perf: detailed control of models service displaying 2026-01-27 22:44:58 +08:00
Junyan Qin
61f3f31edc chore: bump version to 4.8.1 2026-01-27 20:33:55 +08:00
Junyan Qin
3663d9dc10 style: adjust margin in PipelineDetailDialog for improved button alignment 2026-01-27 20:33:17 +08:00
Guanchao Wang
89ec86c530 fix: issue 1936 (#1937) 2026-01-27 20:28:19 +08:00
Junyan Qin
d9ba2a17ff chore: bump version to 4.8.0 2026-01-26 21:12:56 +08:00
Junyan Qin
c4ea6188f9 chore: update layout description to reflect production-grade capabilities for IM bot integration 2026-01-26 21:09:59 +08:00
Guanchao Wang
5d9f6ec763 Feat/monitor (#1928)
* feat: add monitor

* feat: fix tab

* feat: work

* feat: not reliable monitor

* feat: enhance monitoring page layout with integrated filters and refresh button

* feat: add support for runner recording

* feat: add jump button & alignment

* feat: new

* fix: not show query variables in local agent

* fix: pnpm lint and python ruff check

* fix: ruff fromat

* chore: remove unnecessary migration

* style: optimize monitoring page layout and fix sticky filter issues

- Enhanced metric cards with gradient backgrounds and hover effects
- Increased traffic chart height from 200px to 300px
- Adjusted grid layout and spacing for better visual appeal
- Fixed sticky filter area to properly cover parent padding without transparent gaps
- Used negative margins and positioning to eliminate scrolling artifacts
- Matched padding/margins with other pages (pipelines, bots) for consistency
- Removed duplicate title/subtitle from page content
- Added cursor-pointer styling to tab triggers
- Removed border between tab list and tab content

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: apply prettier formatting to monitoring components

- Fixed indentation and spacing in MetricCard.tsx
- Fixed formatting in TrafficChart.tsx
- Applied prettier formatting to page.tsx

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* feat: update HomeSidebar to trigger action on child selection and localize monitoring titles

* refactor: streamline LLM and embedding invocation methods

* feat: add embedding model monitor

* fix: database version

* chore: simplify pnpm-lock.yaml formatting

---------

Co-authored-by: Junyan Qin <rockchinq@gmail.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-26 21:08:23 +08:00
Junyan Qin (Chin)
b73847f1a6 feat: add emoji support to knowledge bases and pipelines (#1935)
* feat: add emoji support to knowledge bases and pipelines

* feat: add optional emoji property to ExternalKBCardVO for enhanced knowledge base representation
2026-01-26 17:37:35 +08:00
Typer_Body
d6e1e79f07 fix: potential copy action bug on windows (#1931)
* fix a bag updata

* Update page.tsx

* Update page.tsx

* Append text area to body for selection

* Update page.tsx

* Update mcp.py
2026-01-25 15:40:11 +08:00
Junyan Qin
525008b8b2 docs: update feature descriptions in multiple language READMEs to include Langflow integration and enhance clarity on production-grade features 2026-01-25 15:28:15 +08:00
Junyan Qin (Chin)
bbf77bac4c feat(user): update Space model provider API keys in UserService (#1932) 2026-01-25 14:15:25 +08:00
Junyan Qin (Chin)
fc6e414be4 feat: add GitHub Actions workflow for linting with Ruff (#1929)
* feat: add GitHub Actions workflow for linting with Ruff

* refactor: rename lint job and add formatting step to Ruff workflow

* chore: run ruff format

* chore: rename Ruff lint job to 'Lint' and add frontend linting workflow
2026-01-23 13:43:12 +08:00
Junyan Qin
e60cb6ad0e fix: ruff check errors 2026-01-23 13:30:44 +08:00
Junyan Qin
c90f2d6a12 chore: update mcp dependency version to 1.25.0 2026-01-20 01:59:19 +08:00
Junyan Qin
fe8a738cd7 fix(i18n): update apiKeyCreatedMessage for clarity across multiple languages 2026-01-20 01:53:49 +08:00
Tiankai Ma
604cc53973 fix(localagent): allow empty func arg (#1921) 2026-01-19 23:42:47 +08:00
Tiankai Ma
195b694ecc feat(telegram): threaded mode support (#1920)
* feat(telegram): reply in threaded mode

* feat(telegram): thread-level isolation
2026-01-19 23:42:17 +08:00
Tiankai Ma
d21f23beee fix(telegram): set reply_to_message_id correctly (#1918) 2026-01-15 18:09:57 +08:00
Junyan Qin
558587883b chore: update project version to 4.7.2 2026-01-13 14:02:00 +08:00
Junyan Qin
2e6a1daf4f feat(mcp): extend mode options in MCPCardVO to include 'http' 2026-01-13 13:59:59 +08:00
Tiankai Ma
1fc5e75f93 feat(mcp): add streamable HTTP and stdio (#1911)
* feat(mcp): add streamable HTTP

alongside with frontend UI change, w/ support for stdio

* fix(mcp): address copilot reviews

* Update src/langbot/pkg/provider/tools/loaders/mcp.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: resolve copilot reviews

* fix: Message -> MessageChunk

* feat: upgrade mcp module

* feat: add i18n

* feat(mcp): enhance MCPCardComponent with mode badge and reorder select items in MCPFormDialog

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: WangCham <651122857@qq.com>
Co-authored-by: Junyan Qin (Chin) <rockchinq@gmail.com>
2026-01-13 13:50:06 +08:00
fdc310
a332206ba3 fix: When the deletion of the thinking chain is activated, since the "continue" is triggered as soon as the thinking begins, it causes a bug in the subsequent judgment that breaks out of the loop impression. (#1913) 2026-01-12 00:14:39 +08:00
Junyan Qin
8e620dc635 fix: remove unreachable assertion in ChatMessageHandler to improve error handling 2026-01-09 23:46:43 +08:00
Junyan Qin
c9a21ebace fix: improve error handling in ChatMessageHandler 2026-01-09 23:23:53 +08:00
116 changed files with 13613 additions and 1064 deletions

60
.github/workflows/lint.yml vendored Normal file
View File

@@ -0,0 +1,60 @@
name: Lint
on:
push:
branches:
- main
- master
- dev
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
jobs:
ruff:
name: Ruff Lint & Format
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --dev
- name: Run ruff check
run: uv run ruff check src
- name: Run ruff format
run: uv run ruff format src --check
frontend:
name: Frontend Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '25'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Install dependencies
working-directory: web
run: pnpm install
- name: Run lint
working-directory: web
run: pnpm lint

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.1"
description = "Production-grade platform for building IM bots"
version = "4.8.1"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
requires-python = ">=3.11,<4.0"
@@ -23,7 +23,7 @@ dependencies = [
"pynacl>=1.5.0", # Required for Discord voice support
"gewechat-client>=0.1.5",
"lark-oapi>=1.4.15",
"mcp>=1.8.1",
"mcp>=1.25.0",
"nakuru-project-idk>=0.0.2.1",
"ollama>=0.4.8",
"openai>1.0.0",
@@ -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.1'
__version__ = '4.8.1'

View File

@@ -85,7 +85,6 @@ class QQOfficialClient:
req: Quart Request 对象
"""
try:
body = await req.get_data()
print(f'[QQ Official] Received request, body length: {len(body)}')
@@ -96,7 +95,6 @@ class QQOfficialClient:
payload = json.loads(body)
if payload.get('op') == 13:
validation_data = payload.get('d')
if not validation_data:
@@ -276,21 +274,21 @@ class QQOfficialClient:
seed = bot_secret
while len(seed) < target_size:
seed *= 2
return seed[:target_size].encode("utf-8")
return seed[:target_size].encode('utf-8')
async def verify(self, validation_payload: dict):
seed = await self.repeat_seed(self.secret)
private_key = ed25519.Ed25519PrivateKey.from_private_bytes(seed)
event_ts = validation_payload.get("event_ts", "")
plain_token = validation_payload.get("plain_token", "")
event_ts = validation_payload.get('event_ts', '')
plain_token = validation_payload.get('plain_token', '')
msg = event_ts + plain_token
# sign
signature = private_key.sign(msg.encode()).hex()
response = {
"plain_token": plain_token,
"signature": signature,
'plain_token': plain_token,
'signature': signature,
}
return response

View File

@@ -36,7 +36,12 @@ class WecomBotEvent(dict):
"""
用户名称
"""
return self.get('username', '') or self.get('from', {}).get('alias', '') or self.get('from', {}).get('name', '') or self.userid
return (
self.get('username', '')
or self.get('from', {}).get('alias', '')
or self.get('from', {}).get('name', '')
or self.userid
)
@property
def chatname(self) -> str:
@@ -121,7 +126,7 @@ class WecomBotEvent(dict):
消息id
"""
return self.get('msgid', '')
@property
def ai_bot_id(self) -> str:
"""

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

@@ -30,7 +30,6 @@ class WebhookRouterGroup(group.RouterGroup):
适配器返回的响应
"""
try:
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
if not runtime_bot:
@@ -39,11 +38,9 @@ class WebhookRouterGroup(group.RouterGroup):
if not runtime_bot.enable:
return quart.jsonify({'error': 'Bot is disabled'}), 403
if not hasattr(runtime_bot.adapter, 'handle_unified_webhook'):
return quart.jsonify({'error': 'Adapter does not support unified webhook'}), 501
response = await runtime_bot.adapter.handle_unified_webhook(
bot_uuid=bot_uuid,
path=path,

View File

@@ -59,7 +59,16 @@ class BotService:
adapter_runtime_values['bot_account_id'] = runtime_bot.adapter.bot_account_id
# Webhook URL for unified webhook adapters (independent of bot running state)
if persistence_bot['adapter'] in ['wecom', 'wecombot', 'officialaccount', 'qqofficial', 'slack', 'wecomcs', 'LINE', 'lark']:
if persistence_bot['adapter'] in [
'wecom',
'wecombot',
'officialaccount',
'qqofficial',
'slack',
'wecomcs',
'LINE',
'lark',
]:
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
webhook_url = f'/bots/{bot_uuid}'
adapter_runtime_values['webhook_url'] = webhook_url

View File

@@ -192,7 +192,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 +354,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

@@ -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
@@ -34,7 +35,6 @@ from .. import taskmgr
from ...telemetry import telemetry as telemetry_module
@stage.stage_class('BuildAppStage')
class BuildAppStage(stage.BootingStage):
"""Build LangBot application"""
@@ -150,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

@@ -9,7 +9,7 @@ class MCPServer(Base):
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse
mode = sqlalchemy.Column(sqlalchemy.String(255), nullable=False) # stdio, sse, http
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(

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

@@ -145,7 +145,7 @@ class ChatMessageHandler(handler.MessageHandler):
query.session.using_conversation.messages.extend(query.resp_messages)
except Exception as e:
error_info = f'{type(e).__name__} {str(e)}'
error_info = f'{traceback.format_exc()}'
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
traceback.print_exc()

View File

@@ -75,10 +75,17 @@ class RuntimeBot:
# Only add to query pool if no webhook requested to skip pipeline
if not skip_pipeline:
launcher_id = event.sender.id
if hasattr(adapter, 'get_launcher_id'):
custom_launcher_id = adapter.get_launcher_id(event)
if custom_launcher_id:
launcher_id = custom_launcher_id
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=event.sender.id,
launcher_id=launcher_id,
sender_id=event.sender.id,
message_event=event,
message_chain=event.message_chain,
@@ -86,7 +93,7 @@ class RuntimeBot:
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
)
else:
await self.logger.info(f'Pipeline skipped for person message due to webhook response')
await self.logger.info('Pipeline skipped for person message due to webhook response')
async def on_group_message(
event: platform_events.GroupMessage,
@@ -111,10 +118,17 @@ class RuntimeBot:
# Only add to query pool if no webhook requested to skip pipeline
if not skip_pipeline:
launcher_id = event.group.id
if hasattr(adapter, 'get_launcher_id'):
custom_launcher_id = adapter.get_launcher_id(event)
if custom_launcher_id:
launcher_id = custom_launcher_id
await self.ap.query_pool.add_query(
bot_uuid=self.bot_entity.uuid,
launcher_type=provider_session.LauncherTypes.GROUP,
launcher_id=event.group.id,
launcher_id=launcher_id,
sender_id=event.sender.id,
message_event=event,
message_chain=event.message_chain,
@@ -122,7 +136,7 @@ class RuntimeBot:
pipeline_uuid=self.bot_entity.use_pipeline_uuid,
)
else:
await self.logger.info(f'Pipeline skipped for group message due to webhook response')
await self.logger.info('Pipeline skipped for group message due to webhook response')
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
self.adapter.register_listener(platform_events.GroupMessage, on_group_message)

View File

@@ -244,7 +244,6 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
lb_msg_list.append(platform_message.Source(id=message.message_id, time=msg_create_time))
if message.message_type == 'text':
element_list = []
@@ -310,7 +309,11 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
]
elif message.message_type == 'audio':
message_content['content'] = [
{'tag': 'audio', 'file_key': message_content['file_key'], "duration": message_content.get('duration',0)}
{
'tag': 'audio',
'file_key': message_content['file_key'],
'duration': message_content.get('duration', 0),
}
]
for ele in message_content['content']:
@@ -367,12 +370,9 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
audio_bytes = response.file.read()
audio_base64 = base64.b64encode(audio_bytes).decode()
# Get content type from response headers
content_type = response.raw.headers.get('content-type', 'audio/mpeg')
mime_main = content_type.split(';')[0].strip()
ext = mimetypes.guess_extension(mime_main) or '.bin'
temp_dir = tempfile.gettempdir()
@@ -418,7 +418,6 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
file_bytes = response.file.read()
file_base64 = base64.b64encode(file_bytes).decode()
file_format = response.raw.headers['content-type']
file_size = len(file_bytes)
@@ -453,7 +452,6 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
)
)
return platform_message.MessageChain(lb_msg_list)

View File

@@ -197,6 +197,10 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
}
if self.config['markdown_card'] is True:
args['parse_mode'] = 'MarkdownV2'
if message_source.source_platform_object.message.message_thread_id:
args['message_thread_id'] = message_source.source_platform_object.message.message_thread_id
if quote_origin:
args['reply_to_message_id'] = message_source.source_platform_object.message.id
@@ -216,8 +220,6 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
components = await TelegramMessageConverter.yiri2target(message, self.bot)
args = {}
message_id = message_source.source_platform_object.message.id
if quote_origin:
args['reply_to_message_id'] = message_source.source_platform_object.message.id
component = components[0]
if message_id not in self.msg_stream_id: # 当消息回复第一次时,发送新消息
@@ -233,6 +235,12 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
'chat_id': message_source.source_platform_object.effective_chat.id,
'text': content,
}
if message_source.source_platform_object.message.message_thread_id:
args['message_thread_id'] = message_source.source_platform_object.message.message_thread_id
if quote_origin:
args['reply_to_message_id'] = message_source.source_platform_object.message.id
if self.config['markdown_card'] is True:
args['parse_mode'] = 'MarkdownV2'
@@ -260,6 +268,24 @@ class TelegramAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
# self.seq = 1 # 消息回复结束之后重置seq
self.msg_stream_id.pop(message_id) # 消息回复结束之后删除流式消息id
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
if not isinstance(event.source_platform_object, Update):
return None
message = event.source_platform_object.message
if not message:
return None
# specifically handle telegram forum topic and private thread(not supported by official client yet but supported by bot api)
if message.message_thread_id:
# check if it is a group
if isinstance(event, platform_events.GroupMessage):
return f'{event.group.id}#{message.message_thread_id}'
elif isinstance(event, platform_events.FriendMessage):
return f'{event.sender.id}#{message.message_thread_id}'
return None
async def is_stream_output_supported(self) -> bool:
is_stream = False
if self.config.get('enable-stream-reply', None):

View File

@@ -18,52 +18,52 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
def split_string_by_bytes(text, limit=2048, encoding='utf-8'):
"""
Splits a string into a list of strings, where each part is at most 'limit' bytes.
Args:
text (str): The original string to split.
limit (int): The maximum byte size for each split part.
encoding (str): The encoding to use (default is 'utf-8').
Returns:
list: A list of split strings.
"""
# 1. Encode the entire string into bytes
bytes_data = text.encode(encoding)
total_len = len(bytes_data)
parts = []
start = 0
while start < total_len:
# 2. Determine the end index for the current chunk
# It shouldn't exceed the total length
end = min(start + limit, total_len)
# 3. Slice the byte array
chunk = bytes_data[start:end]
# 4. Attempt to decode the chunk
# Use errors='ignore' to drop any partial bytes at the end of the chunk
# (e.g., if a 3-byte character was cut after the 2nd byte)
part_str = chunk.decode(encoding, errors='ignore')
# 5. Calculate the actual byte length of the successfully decoded string
# This tells us exactly where the valid character boundary ended
part_bytes = part_str.encode(encoding)
part_len = len(part_bytes)
# Safety check: Prevent infinite loop if limit is too small (e.g., limit=1 for a Chinese char)
if part_len == 0 and end < total_len:
# Force advance by 1 byte to consume the un-decodable byte or raise error
# Here we just treat it as a part to avoid stuck loops, though it might be invalid
start += 1
start += 1
continue
parts.append(part_str)
# 6. Move the start pointer by the actual length consumed
start += part_len
return parts
@@ -75,13 +75,15 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
for msg in message_chain:
if type(msg) is platform_message.Plain:
chunks = split_string_by_bytes(msg.text)
content_list.extend([
{
'type': 'text',
'content': chunk,
}
for chunk in chunks
])
content_list.extend(
[
{
'type': 'text',
'content': chunk,
}
for chunk in chunks
]
)
elif type(msg) is platform_message.Image:
content_list.append(
{

View File

@@ -56,7 +56,7 @@ class WebhookPusher:
# Check if any webhook responded with skip_pipeline=true
for result in results:
if isinstance(result, dict) and result.get('skip_pipeline') is True:
self.logger.info(f'Webhook responded with skip_pipeline=true, skipping pipeline for person message')
self.logger.info('Webhook responded with skip_pipeline=true, skipping pipeline for person message')
return True
return False
@@ -103,7 +103,7 @@ class WebhookPusher:
# Check if any webhook responded with skip_pipeline=true
for result in results:
if isinstance(result, dict) and result.get('skip_pipeline') is True:
self.logger.info(f'Webhook responded with skip_pipeline=true, skipping pipeline for group message')
self.logger.info('Webhook responded with skip_pipeline=true, skipping pipeline for group message')
return True
return False

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

@@ -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 = {}
@@ -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

@@ -20,6 +20,24 @@ class GeminiChatCompletions(chatcmpl.OpenAIChatCompletions):
'timeout': 120,
}
def _add_thought_signature_to_messages(self, messages: list[dict]) -> list[dict]:
"""Add thought_signature to tool_calls in messages for Gemini API compatibility
Gemini API requires a thought_signature field in function call parts.
See: https://ai.google.dev/gemini-api/docs/thought-signatures
Note: This function modifies the dictionaries in the messages list in place.
"""
for msg in messages:
if 'tool_calls' in msg and msg['tool_calls']:
# Ensure we're working with a mutable copy of tool_calls
if not isinstance(msg['tool_calls'], list):
continue
for tool_call in msg['tool_calls']:
if isinstance(tool_call, dict) and 'thought_signature' not in tool_call:
tool_call['thought_signature'] = ''
return messages
async def _closure_stream(
self,
query: pipeline_query.Query,
@@ -42,6 +60,9 @@ class GeminiChatCompletions(chatcmpl.OpenAIChatCompletions):
# 设置此次请求中的messages
messages = req_messages.copy()
# Add thought_signature to tool_calls for Gemini compatibility
messages = self._add_thought_signature_to_messages(messages)
# 检查vision
for msg in messages:
if 'content' in msg and isinstance(msg['content'], list):
@@ -140,3 +161,29 @@ class GeminiChatCompletions(chatcmpl.OpenAIChatCompletions):
yield provider_message.MessageChunk(**chunk_data)
chunk_idx += 1
async def _closure(
self,
query: pipeline_query.Query,
req_messages: list[dict],
use_model: requester.RuntimeLLMModel,
use_funcs: list[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
remove_think: bool = False,
) -> tuple[provider_message.Message, dict]:
"""Override _closure to add thought_signature to messages"""
# Make a shallow copy to avoid mutating the caller's list
messages = req_messages.copy()
# Add thought_signature to tool_calls for Gemini compatibility
messages = self._add_thought_signature_to_messages(messages)
# Call parent implementation
return await super()._closure(
query=query,
req_messages=messages,
use_model=use_model,
use_funcs=use_funcs,
extra_args=extra_args,
remove_think=remove_think,
)

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

@@ -51,9 +51,10 @@ class SeekDBEmbedding(requester.ProviderAPIRequester):
await self.initialize()
if self._embedding_function is None:
raise RuntimeError("SeekDB embedding function initialization failed")
raise RuntimeError('SeekDB embedding function initialization failed')
return self._embedding_function(input_text)
except Exception as e:
from .. import errors
raise errors.RequesterError(f'SeekDB embedding failed: {str(e)}')

View File

@@ -529,7 +529,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
think_end = True
elif think_end or not think_start:
pending_agent_message += chunk['answer']
if think_start:
if think_start and not think_end:
continue
else:

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,
@@ -212,19 +212,34 @@ class LocalAgentRunner(runner.RequestRunner):
try:
func = tool_call.function
parameters = json.loads(func.arguments)
if func.arguments:
parameters = json.loads(func.arguments)
else:
parameters = {}
func_ret = await self.ap.tool_mgr.execute_func_call(func.name, parameters, query=query)
# Handle return value content
tool_content = None
if (
isinstance(func_ret, list)
and len(func_ret) > 0
and isinstance(func_ret[0], provider_message.ContentElement)
):
tool_content = func_ret
else:
tool_content = json.dumps(func_ret, ensure_ascii=False)
if is_stream:
msg = provider_message.MessageChunk(
role='tool',
content=json.dumps(func_ret, ensure_ascii=False),
content=tool_content,
tool_call_id=tool_call.id,
)
else:
msg = provider_message.Message(
role='tool',
content=json.dumps(func_ret, ensure_ascii=False),
content=tool_content,
tool_call_id=tool_call.id,
)
@@ -250,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,
@@ -306,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

@@ -68,15 +68,16 @@ class N8nServiceAPIRunner(runner.RequestRunner):
return plain_text
async def _process_stream_response(self, response: aiohttp.ClientResponse) -> typing.AsyncGenerator[
provider_message.Message, None]:
async def _process_stream_response(
self, response: aiohttp.ClientResponse
) -> typing.AsyncGenerator[provider_message.Message, None]:
"""处理流式响应——支持部分 JSON 和多个 JSON 对象在同一 chunk 的情况"""
full_content = ""
full_content = ''
chunk_idx = 0
is_final = False
message_idx = 0
buffer = ""
buffer = ''
decoder = json.JSONDecoder()
async for raw_chunk in response.content.iter_chunked(1024):
@@ -129,7 +130,7 @@ class N8nServiceAPIRunner(runner.RequestRunner):
preview = chunk_str[:200]
except Exception:
preview = '<unavailable>'
self.ap.logger.warning(f"Failed to process chunk: {e}; chunk preview: {preview}")
self.ap.logger.warning(f'Failed to process chunk: {e}; chunk preview: {preview}')
# 流结束后,尝试解析残余 buffer
if buffer:
@@ -151,7 +152,7 @@ class N8nServiceAPIRunner(runner.RequestRunner):
)
except Exception as e:
preview = buffer[:200]
self.ap.logger.warning(f"Failed to parse remaining buffer: {e}; buffer preview: {preview}")
self.ap.logger.warning(f'Failed to parse remaining buffer: {e}; buffer preview: {preview}')
async def _call_webhook(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
"""调用n8n webhook"""
@@ -165,7 +166,7 @@ class N8nServiceAPIRunner(runner.RequestRunner):
# 准备请求数据
payload = {
# 基本消息内容
'chatInput' :plain_text, # 考虑到之前用户直接用的message model这里添加新键
'chatInput': plain_text, # 考虑到之前用户直接用的message model这里添加新键
'message': plain_text,
'user_message_text': plain_text,
'conversation_id': query.session.using_conversation.uuid,
@@ -217,57 +218,49 @@ class N8nServiceAPIRunner(runner.RequestRunner):
# 调用webhook
async with aiohttp.ClientSession() as session:
if is_stream:
# 流式请求
async with session.post(
self.webhook_url,
json=payload,
headers=headers,
auth=auth,
timeout=self.timeout
) as response:
if is_stream:
# 流式请求
async with session.post(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response:
if response.status != 200:
error_text = await response.text()
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
# 处理流式响应
async for chunk in self._process_stream_response(response):
yield chunk
else:
async with session.post(
self.webhook_url, json=payload, headers=headers, auth=auth, timeout=self.timeout
) as response:
try:
async for chunk in self._process_stream_response(response):
output_content = chunk.content if chunk.is_final else ''
except:
# 非流式请求(保持原有逻辑)
if response.status != 200:
error_text = await response.text()
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
# 处理流式响应
async for chunk in self._process_stream_response(response):
yield chunk
else:
async with session.post(
self.webhook_url,
json=payload,
headers=headers,
auth=auth,
timeout=self.timeout
) as response:
try:
async for chunk in self._process_stream_response(response):
output_content = chunk.content if chunk.is_final else ''
except:
# 非流式请求(保持原有逻辑)
if response.status != 200:
error_text = await response.text()
self.ap.logger.error(f'n8n webhook call failed: {response.status}, {error_text}')
raise Exception(f'n8n webhook call failed: {response.status}, {error_text}')
# 解析响应
response_data = await response.json()
self.ap.logger.debug(f'n8n webhook response: {response_data}')
# 解析响应
response_data = await response.json()
self.ap.logger.debug(f'n8n webhook response: {response_data}')
# 从响应中提取输出
if self.output_key in response_data:
output_content = response_data[self.output_key]
else:
# 如果没有指定的输出键,则使用整个响应
output_content = json.dumps(response_data, ensure_ascii=False)
# 从响应中提取输出
if self.output_key in response_data:
output_content = response_data[self.output_key]
else:
# 如果没有指定的输出键,则使用整个响应
output_content = json.dumps(response_data, ensure_ascii=False)
# 返回消息
yield provider_message.Message(
role='assistant',
content=output_content,
)
# 返回消息
yield provider_message.Message(
role='assistant',
content=output_content,
)
except Exception as e:
self.ap.logger.error(f'n8n webhook call exception: {str(e)}')
raise N8nAPIError(f'n8n webhook call exception: {str(e)}')
@@ -275,4 +268,4 @@ class N8nServiceAPIRunner(runner.RequestRunner):
async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
"""运行请求"""
async for msg in self._call_webhook(query):
yield msg
yield msg

View File

@@ -7,14 +7,18 @@ import traceback
from langbot_plugin.api.entities.events import pipeline_query
import sqlalchemy
import asyncio
import httpx
import uuid as uuid_module
from mcp import ClientSession, StdioServerParameters
from mcp.client.stdio import stdio_client
from mcp.client.sse import sse_client
from mcp.client.streamable_http import streamable_http_client
from .. import loader
from ....core import app
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
import langbot_plugin.api.entities.builtin.provider.message as provider_message
from ....entity.persistence import mcp as persistence_mcp
@@ -35,7 +39,7 @@ class RuntimeMCPSession:
server_config: dict
session: ClientSession
session: ClientSession | None
exit_stack: AsyncExitStack
@@ -52,6 +56,8 @@ class RuntimeMCPSession:
_ready_event: asyncio.Event
error_message: str | None = None
def __init__(self, server_name: str, server_config: dict, enable: bool, ap: app.Application):
self.server_name = server_name
self.server_uuid = server_config.get('uuid', '')
@@ -100,6 +106,24 @@ class RuntimeMCPSession:
await self.session.initialize()
async def _init_streamable_http_server(self):
transport = await self.exit_stack.enter_async_context(
streamable_http_client(
self.server_config['url'],
http_client=httpx.AsyncClient(
headers=self.server_config.get('headers', {}),
timeout=self.server_config.get('timeout', 10),
follow_redirects=True,
),
)
)
read, write, _ = transport
self.session = await self.exit_stack.enter_async_context(ClientSession(read, write))
await self.session.initialize()
async def _lifecycle_loop(self):
"""在后台任务中管理整个MCP会话的生命周期"""
try:
@@ -107,6 +131,8 @@ class RuntimeMCPSession:
await self._init_stdio_python_server()
elif self.server_config['mode'] == 'sse':
await self._init_sse_server()
elif self.server_config['mode'] == 'http':
await self._init_streamable_http_server()
else:
raise ValueError(f'无法识别 MCP 服务器类型: {self.server_name}: {self.server_config}')
@@ -122,6 +148,7 @@ class RuntimeMCPSession:
except Exception as e:
self.status = MCPSessionStatus.ERROR
self.error_message = str(e)
self.ap.logger.error(f'Error in MCP session lifecycle {self.server_name}: {e}\n{traceback.format_exc()}')
# 即使出错也要设置ready事件让start()方法知道初始化已完成
self._ready_event.set()
@@ -154,6 +181,9 @@ class RuntimeMCPSession:
raise Exception('Connection failed, please check URL')
async def refresh(self):
if not self.session:
return
self.functions.clear()
tools = await self.session.list_tools()
@@ -163,18 +193,36 @@ class RuntimeMCPSession:
for tool in tools.tools:
async def func(*, _tool=tool, **kwargs):
if not self.session:
raise Exception('MCP session is not connected')
result = await self.session.call_tool(_tool.name, kwargs)
if result.isError:
raise Exception(result.content[0].text)
return result.content[0].text
error_texts = []
for content in result.content:
if content.type == 'text':
error_texts.append(content.text)
raise Exception('\n'.join(error_texts) if error_texts else 'Unknown error from MCP tool')
result_contents: list[provider_message.ContentElement] = []
for content in result.content:
if content.type == 'text':
result_contents.append(provider_message.ContentElement.from_text(content.text))
elif content.type == 'image':
result_contents.append(provider_message.ContentElement.from_image_base64(content.image_base64))
elif content.type == 'resource':
# TODO: Handle resource content
pass
return result_contents
func.__name__ = tool.name
self.functions.append(
resource_tool.LLMTool(
name=tool.name,
human_desc=tool.description,
description=tool.description,
human_desc=tool.description or '',
description=tool.description or '',
parameters=tool.inputSchema,
func=func,
)
@@ -186,6 +234,7 @@ class RuntimeMCPSession:
def get_runtime_info_dict(self) -> dict:
return {
'status': self.status.value,
'error_message': self.error_message,
'tool_count': len(self.get_tools()),
'tools': [
{
@@ -289,13 +338,10 @@ class MCPLoader(loader.ToolLoader):
"""
uuid_ = server_config.get('uuid')
if not uuid_:
self.ap.logger.warning(
'Server UUID is None for MCP server, maybe testing in the config page.'
)
self.ap.logger.warning('Server UUID is None for MCP server, maybe testing in the config page.')
uuid_ = str(uuid_module.uuid4())
server_config['uuid'] = uuid_
name = server_config['name']
uuid = server_config['uuid']
mode = server_config['mode']

View File

@@ -35,13 +35,15 @@ class Embedder(BaseService):
# get embeddings (batch size limit: 64 for OpenAI)
MAX_BATCH_SIZE = 64
embeddings_list: list[list[float]] = []
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 = chunks[i : i + MAX_BATCH_SIZE]
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

View File

@@ -55,12 +55,7 @@ class VectorDBManager:
user = pgvector_config.get('user', 'postgres')
password = pgvector_config.get('password', 'postgres')
self.vector_db = PgVectorDatabase(
self.ap,
host=host,
port=port,
database=database,
user=user,
password=password
self.ap, host=host, port=port, database=database, user=user, password=password
)
self.ap.logger.info('Initialized pgvector database backend.')

View File

@@ -10,7 +10,7 @@ from langbot.pkg.core import app
class MilvusVectorDatabase(VectorDatabase):
"""Milvus vector database implementation"""
def __init__(self, ap: app.Application, uri: str = "milvus.db", token: str = None, db_name: str = None):
def __init__(self, ap: app.Application, uri: str = 'milvus.db', token: str = None, db_name: str = None):
"""Initialize Milvus vector database
Args:
@@ -34,32 +34,32 @@ class MilvusVectorDatabase(VectorDatabase):
self.client = MilvusClient(uri=self.uri, token=self.token, db_name=self.db_name)
else:
self.client = MilvusClient(uri=self.uri, db_name=self.db_name)
self.ap.logger.info(f"Connected to Milvus at {self.uri}")
self.ap.logger.info(f'Connected to Milvus at {self.uri}')
except Exception as e:
self.ap.logger.error(f"Failed to connect to Milvus: {e}")
self.ap.logger.error(f'Failed to connect to Milvus: {e}')
raise
@staticmethod
def _normalize_collection_name(collection: str) -> str:
"""Normalize collection name to comply with Milvus naming requirements.
Milvus requirements:
- First character must be an underscore or letter
- Can only contain numbers, letters and underscores
Args:
collection: Original collection name (e.g., UUID with hyphens)
Returns:
Normalized collection name that complies with Milvus requirements
"""
# Replace hyphens with underscores
normalized = collection.replace('-', '_')
# If first character is not a letter or underscore, prepend 'kb_'
if normalized and not (normalized[0].isalpha() or normalized[0] == '_'):
normalized = 'kb_' + normalized
return normalized
async def _ensure_vector_index(self, collection: str) -> None:
@@ -70,15 +70,11 @@ class MilvusVectorDatabase(VectorDatabase):
"""
index_params = IndexParams()
index_params.add_index(
field_name="vector",
index_type="AUTOINDEX",
metric_type="COSINE",
)
await asyncio.to_thread(
self.client.create_index,
collection_name=collection,
index_params=index_params
field_name='vector',
index_type='AUTOINDEX',
metric_type='COSINE',
)
await asyncio.to_thread(self.client.create_index, collection_name=collection, index_params=index_params)
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None):
"""Internal method to get or create a Milvus collection with proper configuration.
@@ -89,14 +85,12 @@ class MilvusVectorDatabase(VectorDatabase):
"""
# Normalize collection name for Milvus compatibility
collection = self._normalize_collection_name(collection)
if collection in self._collections:
return collection
# Check if collection exists
has_collection = await asyncio.to_thread(
self.client.has_collection, collection_name=collection
)
has_collection = await asyncio.to_thread(self.client.has_collection, collection_name=collection)
if not has_collection:
# Default dimension if not specified (for backward compatibility)
@@ -104,24 +98,26 @@ class MilvusVectorDatabase(VectorDatabase):
vector_size = 1536
fields = [
FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=255),
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=vector_size),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name="file_id", dtype=DataType.VARCHAR, max_length=255),
FieldSchema(name="chunk_uuid", dtype=DataType.VARCHAR, max_length=255),
FieldSchema(name='id', dtype=DataType.VARCHAR, is_primary=True, max_length=255),
FieldSchema(name='vector', dtype=DataType.FLOAT_VECTOR, dim=vector_size),
FieldSchema(name='text', dtype=DataType.VARCHAR, max_length=65535),
FieldSchema(name='file_id', dtype=DataType.VARCHAR, max_length=255),
FieldSchema(name='chunk_uuid', dtype=DataType.VARCHAR, max_length=255),
]
schema = CollectionSchema(fields=fields, description="LangBot knowledge base vectors")
schema = CollectionSchema(fields=fields, description='LangBot knowledge base vectors')
await asyncio.to_thread(
self.client.create_collection,
collection_name=collection,
schema=schema,
metric_type="COSINE",
metric_type='COSINE',
)
await self._ensure_vector_index(collection)
self.ap.logger.info(f"Created Milvus collection '{collection}' with dimension={vector_size}, index=AUTOINDEX")
self.ap.logger.info(
f"Created Milvus collection '{collection}' with dimension={vector_size}, index=AUTOINDEX"
)
else:
# Ensure index exists for existing collection
await self._ensure_index_if_missing(collection)
@@ -137,11 +133,8 @@ class MilvusVectorDatabase(VectorDatabase):
collection: Normalized collection name
"""
try:
indexes = await asyncio.to_thread(
self.client.list_indexes,
collection_name=collection
)
if "vector" not in indexes:
indexes = await asyncio.to_thread(self.client.list_indexes, collection_name=collection)
if 'vector' not in indexes:
await self._ensure_vector_index(collection)
self.ap.logger.info(f"Created index for existing Milvus collection '{collection}'")
except Exception as e:
@@ -172,7 +165,7 @@ class MilvusVectorDatabase(VectorDatabase):
metadatas: List of metadata dictionaries for each vector
"""
collection = self._normalize_collection_name(collection)
if not embeddings_list:
return
@@ -184,39 +177,30 @@ class MilvusVectorDatabase(VectorDatabase):
data = []
for i, vector_id in enumerate(ids):
entry = {
"id": vector_id,
"vector": embeddings_list[i],
'id': vector_id,
'vector': embeddings_list[i],
}
# Add metadata fields
if metadatas and i < len(metadatas):
metadata = metadatas[i]
# Add common metadata fields
if "text" in metadata:
entry["text"] = metadata["text"]
if "file_id" in metadata:
entry["file_id"] = metadata["file_id"]
if "uuid" in metadata:
entry["chunk_uuid"] = metadata["uuid"]
if 'text' in metadata:
entry['text'] = metadata['text']
if 'file_id' in metadata:
entry['file_id'] = metadata['file_id']
if 'uuid' in metadata:
entry['chunk_uuid'] = metadata['uuid']
data.append(entry)
# Insert data into Milvus
await asyncio.to_thread(
self.client.insert,
collection_name=collection,
data=data
)
await asyncio.to_thread(self.client.insert, collection_name=collection, data=data)
# Load collection for searching (Milvus requires this)
await asyncio.to_thread(
self.client.load_collection,
collection_name=collection
)
await asyncio.to_thread(self.client.load_collection, collection_name=collection)
self.ap.logger.info(f"Added {len(ids)} embeddings to Milvus collection '{collection}'")
async def search(
self, collection: str, query_embedding: list[float], k: int = 5
) -> Dict[str, Any]:
async def search(self, collection: str, query_embedding: list[float], k: int = 5) -> Dict[str, Any]:
"""Search for similar vectors in Milvus collection
Args:
@@ -231,10 +215,7 @@ class MilvusVectorDatabase(VectorDatabase):
await self.get_or_create_collection(collection)
# Perform search
search_params = {
"metric_type": "COSINE",
"params": {}
}
search_params = {'metric_type': 'COSINE', 'params': {}}
results = await asyncio.to_thread(
self.client.search,
@@ -242,7 +223,7 @@ class MilvusVectorDatabase(VectorDatabase):
data=[query_embedding],
limit=k,
search_params=search_params,
output_fields=["text", "file_id", "chunk_uuid"]
output_fields=['text', 'file_id', 'chunk_uuid'],
)
# Convert results to Chroma-compatible format
@@ -253,30 +234,24 @@ class MilvusVectorDatabase(VectorDatabase):
if results and len(results) > 0:
for hit in results[0]:
ids.append(hit.get("id", ""))
distances.append(hit.get("distance", 0.0))
ids.append(hit.get('id', ''))
distances.append(hit.get('distance', 0.0))
# Build metadata from entity fields
entity = hit.get("entity", {})
entity = hit.get('entity', {})
metadata = {}
if "text" in entity:
metadata["text"] = entity["text"]
if "file_id" in entity:
metadata["file_id"] = entity["file_id"]
if "chunk_uuid" in entity:
metadata["uuid"] = entity["chunk_uuid"]
if 'text' in entity:
metadata['text'] = entity['text']
if 'file_id' in entity:
metadata['file_id'] = entity['file_id']
if 'chunk_uuid' in entity:
metadata['uuid'] = entity['chunk_uuid']
metadatas.append(metadata)
# Return in Chroma-compatible format (nested lists)
result = {
"ids": [ids],
"distances": [distances],
"metadatas": [metadatas]
}
result = {'ids': [ids], 'distances': [distances], 'metadatas': [metadatas]}
self.ap.logger.info(
f"Milvus search in '{collection}' returned {len(ids)} results"
)
self.ap.logger.info(f"Milvus search in '{collection}' returned {len(ids)} results")
return result
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
@@ -290,14 +265,8 @@ class MilvusVectorDatabase(VectorDatabase):
await self.get_or_create_collection(collection)
# Delete entities matching the file_id
await asyncio.to_thread(
self.client.delete,
collection_name=collection,
filter=f'file_id == "{file_id}"'
)
self.ap.logger.info(
f"Deleted embeddings from Milvus collection '{collection}' with file_id: {file_id}"
)
await asyncio.to_thread(self.client.delete, collection_name=collection, filter=f'file_id == "{file_id}"')
self.ap.logger.info(f"Deleted embeddings from Milvus collection '{collection}' with file_id: {file_id}")
async def delete_collection(self, collection: str):
"""Delete a Milvus collection
@@ -306,18 +275,14 @@ class MilvusVectorDatabase(VectorDatabase):
collection: Collection name to delete
"""
collection = self._normalize_collection_name(collection)
self._collections.discard(collection)
# Check if collection exists before attempting deletion
has_collection = await asyncio.to_thread(
self.client.has_collection, collection_name=collection
)
has_collection = await asyncio.to_thread(self.client.has_collection, collection_name=collection)
if has_collection:
await asyncio.to_thread(
self.client.drop_collection, collection_name=collection
)
await asyncio.to_thread(self.client.drop_collection, collection_name=collection)
self.ap.logger.info(f"Deleted Milvus collection '{collection}'")
else:
self.ap.logger.warning(f"Milvus collection '{collection}' not found")

View File

@@ -1,19 +1,18 @@
from __future__ import annotations
import asyncio
from typing import Any, Dict
from sqlalchemy import create_engine, text, Column, String, Text
from sqlalchemy.orm import declarative_base, sessionmaker, Session
from sqlalchemy.orm import declarative_base
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from pgvector.sqlalchemy import Vector
from langbot.pkg.vector.vdb import VectorDatabase
from langbot.pkg.core import app
import uuid
Base = declarative_base()
class PgVectorEntry(Base):
"""SQLAlchemy model for pgvector entries"""
__tablename__ = 'langbot_vectors'
id = Column(String, primary_key=True)
@@ -31,11 +30,11 @@ class PgVectorDatabase(VectorDatabase):
self,
ap: app.Application,
connection_string: str = None,
host: str = "localhost",
host: str = 'localhost',
port: int = 5432,
database: str = "langbot",
user: str = "postgres",
password: str = "postgres"
database: str = 'langbot',
user: str = 'postgres',
password: str = 'postgres',
):
"""Initialize pgvector database
@@ -54,14 +53,10 @@ class PgVectorDatabase(VectorDatabase):
if connection_string:
self.connection_string = connection_string
else:
self.connection_string = (
f"postgresql+psycopg://{user}:{password}@{host}:{port}/{database}"
)
self.connection_string = f'postgresql+psycopg://{user}:{password}@{host}:{port}/{database}'
self.async_connection_string = self.connection_string.replace(
"postgresql://", "postgresql+asyncpg://"
).replace(
"postgresql+psycopg://", "postgresql+asyncpg://"
self.async_connection_string = self.connection_string.replace('postgresql://', 'postgresql+asyncpg://').replace(
'postgresql+psycopg://', 'postgresql+asyncpg://'
)
self.engine = None
@@ -75,35 +70,25 @@ class PgVectorDatabase(VectorDatabase):
"""Initialize database connection and create tables"""
try:
# Create async engine for async operations
self.async_engine = create_async_engine(
self.async_connection_string,
echo=False,
pool_pre_ping=True
)
self.AsyncSessionLocal = async_sessionmaker(
self.async_engine,
class_=AsyncSession,
expire_on_commit=False
)
self.async_engine = create_async_engine(self.async_connection_string, echo=False, pool_pre_ping=True)
self.AsyncSessionLocal = async_sessionmaker(self.async_engine, class_=AsyncSession, expire_on_commit=False)
# Create sync engine for table creation
sync_connection_string = self.connection_string.replace(
"postgresql+asyncpg://", "postgresql+psycopg://"
)
sync_connection_string = self.connection_string.replace('postgresql+asyncpg://', 'postgresql+psycopg://')
self.engine = create_engine(sync_connection_string, echo=False)
# Create pgvector extension and tables
with self.engine.connect() as conn:
# Enable pgvector extension
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.execute(text('CREATE EXTENSION IF NOT EXISTS vector'))
conn.commit()
# Create tables
Base.metadata.create_all(self.engine)
self.ap.logger.info(f"Connected to PostgreSQL with pgvector")
self.ap.logger.info('Connected to PostgreSQL with pgvector')
except Exception as e:
self.ap.logger.error(f"Failed to connect to PostgreSQL: {e}")
self.ap.logger.error(f'Failed to connect to PostgreSQL: {e}')
raise
async def get_or_create_collection(self, collection: str):
@@ -144,24 +129,20 @@ class PgVectorDatabase(VectorDatabase):
id=vector_id,
collection=collection,
embedding=embeddings_list[i],
text=metadata.get("text", ""),
file_id=metadata.get("file_id", ""),
chunk_uuid=metadata.get("uuid", "")
text=metadata.get('text', ''),
file_id=metadata.get('file_id', ''),
chunk_uuid=metadata.get('uuid', ''),
)
session.add(entry)
await session.commit()
self.ap.logger.info(
f"Added {len(ids)} embeddings to pgvector collection '{collection}'"
)
self.ap.logger.info(f"Added {len(ids)} embeddings to pgvector collection '{collection}'")
except Exception as e:
await session.rollback()
self.ap.logger.error(f"Error adding embeddings to pgvector: {e}")
self.ap.logger.error(f'Error adding embeddings to pgvector: {e}')
raise
async def search(
self, collection: str, query_embedding: list[float], k: int = 5
) -> Dict[str, Any]:
async def search(self, collection: str, query_embedding: list[float], k: int = 5) -> Dict[str, Any]:
"""Search for similar vectors using cosine distance
Args:
@@ -177,7 +158,7 @@ class PgVectorDatabase(VectorDatabase):
async with self.AsyncSessionLocal() as session:
try:
# Use cosine distance for similarity search
from sqlalchemy import select, func
from sqlalchemy import select
# Query for similar vectors
stmt = (
@@ -186,7 +167,7 @@ class PgVectorDatabase(VectorDatabase):
PgVectorEntry.text,
PgVectorEntry.file_id,
PgVectorEntry.chunk_uuid,
PgVectorEntry.embedding.cosine_distance(query_embedding).label('distance')
PgVectorEntry.embedding.cosine_distance(query_embedding).label('distance'),
)
.filter(PgVectorEntry.collection == collection)
.order_by(PgVectorEntry.embedding.cosine_distance(query_embedding))
@@ -204,25 +185,17 @@ class PgVectorDatabase(VectorDatabase):
for row in rows:
ids.append(row.id)
distances.append(float(row.distance))
metadatas.append({
"text": row.text or "",
"file_id": row.file_id or "",
"uuid": row.chunk_uuid or ""
})
metadatas.append(
{'text': row.text or '', 'file_id': row.file_id or '', 'uuid': row.chunk_uuid or ''}
)
result_dict = {
"ids": [ids],
"distances": [distances],
"metadatas": [metadatas]
}
result_dict = {'ids': [ids], 'distances': [distances], 'metadatas': [metadatas]}
self.ap.logger.info(
f"pgvector search in '{collection}' returned {len(ids)} results"
)
self.ap.logger.info(f"pgvector search in '{collection}' returned {len(ids)} results")
return result_dict
except Exception as e:
self.ap.logger.error(f"Error searching pgvector: {e}")
self.ap.logger.error(f'Error searching pgvector: {e}')
raise
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
@@ -239,8 +212,7 @@ class PgVectorDatabase(VectorDatabase):
from sqlalchemy import delete
stmt = delete(PgVectorEntry).where(
PgVectorEntry.collection == collection,
PgVectorEntry.file_id == file_id
PgVectorEntry.collection == collection, PgVectorEntry.file_id == file_id
)
await session.execute(stmt)
await session.commit()
@@ -250,7 +222,7 @@ class PgVectorDatabase(VectorDatabase):
)
except Exception as e:
await session.rollback()
self.ap.logger.error(f"Error deleting from pgvector: {e}")
self.ap.logger.error(f'Error deleting from pgvector: {e}')
raise
async def delete_collection(self, collection: str):
@@ -266,16 +238,14 @@ class PgVectorDatabase(VectorDatabase):
try:
from sqlalchemy import delete
stmt = delete(PgVectorEntry).where(
PgVectorEntry.collection == collection
)
stmt = delete(PgVectorEntry).where(PgVectorEntry.collection == collection)
await session.execute(stmt)
await session.commit()
self.ap.logger.info(f"Deleted pgvector collection '{collection}'")
except Exception as e:
await session.rollback()
self.ap.logger.error(f"Error deleting pgvector collection: {e}")
self.ap.logger.error(f'Error deleting pgvector collection: {e}')
raise
async def close(self):

View File

@@ -3,10 +3,8 @@ from __future__ import annotations
import asyncio
from typing import Any, Dict, List
import sqlalchemy
from langbot.pkg.core import app
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.vector.vdb import VectorDatabase
try:
@@ -87,14 +85,16 @@ class SeekDBVectorDatabase(VectorDatabase):
self._collections: Dict[str, Any] = {}
self._collection_configs: Dict[str, HNSWConfiguration] = {}
self._escape_table = str.maketrans({
'\x00': '',
'\\': '\\\\',
'"': '\\"',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
})
self._escape_table = str.maketrans(
{
'\x00': '',
'\\': '\\\\',
'"': '\\"',
'\n': '\\n',
'\r': '\\r',
'\t': '\\t',
}
)
async def _get_or_create_collection_internal(self, collection: str, vector_size: int = None) -> Any:
"""Internal method to get or create a collection with proper configuration."""
@@ -133,8 +133,10 @@ class SeekDBVectorDatabase(VectorDatabase):
def _clean_metadata(self, meta: Dict[str, Any]) -> Dict[str, Any]:
"""SeekDB metadata doesn't support \\ and ", insert will error 3104"""
return {
k: v.translate(self._escape_table) if isinstance(v, str)
else v if v is None or isinstance(v, (int, float, bool))
k: v.translate(self._escape_table)
if isinstance(v, str)
else v
if v is None or isinstance(v, (int, float, bool))
else str(v)
for k, v in meta.items()
if v is not None
@@ -145,11 +147,7 @@ class SeekDBVectorDatabase(VectorDatabase):
return await self._get_or_create_collection_internal(collection)
async def add_embeddings(
self,
collection: str,
ids: List[str],
embeddings_list: List[List[float]],
metadatas: List[Dict[str, Any]]
self, collection: str, ids: List[str], embeddings_list: List[List[float]], metadatas: List[Dict[str, Any]]
) -> None:
"""Add vector embeddings to the specified collection.

View File

@@ -9,27 +9,28 @@ from typing import Any
def _apply_env_overrides_to_config(cfg: dict) -> dict:
"""Apply environment variable overrides to data/config.yaml
Environment variables should be uppercase and use __ (double underscore)
Environment variables should be uppercase and use __ (double underscore)
to represent nested keys. For example:
- CONCURRENCY__PIPELINE overrides concurrency.pipeline
- PLUGIN__RUNTIME_WS_URL overrides plugin.runtime_ws_url
Arrays and dict types are ignored.
Args:
cfg: Configuration dictionary
Returns:
Updated configuration dictionary
"""
def convert_value(value: str, original_value: Any) -> Any:
"""Convert string value to appropriate type based on original value
Args:
value: String value from environment variable
original_value: Original value to infer type from
Returns:
Converted value (falls back to string if conversion fails)
"""
@@ -49,7 +50,7 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
return value
else:
return value
# Process environment variables
for env_key, env_value in os.environ.items():
# Check if the environment variable is uppercase and contains __
@@ -57,18 +58,18 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
continue
if '__' not in env_key:
continue
# Convert environment variable name to config path
# e.g., CONCURRENCY__PIPELINE -> ['concurrency', 'pipeline']
keys = [key.lower() for key in env_key.split('__')]
# Navigate to the target value and validate the path
current = cfg
for i, key in enumerate(keys):
if not isinstance(current, dict) or key not in current:
break
if i == len(keys) - 1:
# At the final key - check if it's a scalar value
if isinstance(current[key], (dict, list)):
@@ -81,248 +82,182 @@ def _apply_env_overrides_to_config(cfg: dict) -> dict:
else:
# Navigate deeper
current = current[key]
return cfg
class TestEnvOverrides:
"""Test environment variable override functionality"""
def test_simple_string_override(self):
"""Test overriding a simple string value"""
cfg = {
'api': {
'port': 5300
}
}
cfg = {'api': {'port': 5300}}
# Set environment variable
os.environ['API__PORT'] = '8080'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['port'] == 8080
# Cleanup
del os.environ['API__PORT']
def test_nested_key_override(self):
"""Test overriding nested keys with __ delimiter"""
cfg = {
'concurrency': {
'pipeline': 20,
'session': 1
}
}
cfg = {'concurrency': {'pipeline': 20, 'session': 1}}
os.environ['CONCURRENCY__PIPELINE'] = '50'
result = _apply_env_overrides_to_config(cfg)
assert result['concurrency']['pipeline'] == 50
assert result['concurrency']['session'] == 1 # Unchanged
del os.environ['CONCURRENCY__PIPELINE']
def test_deep_nested_override(self):
"""Test overriding deeply nested keys"""
cfg = {
'system': {
'jwt': {
'expire': 604800,
'secret': ''
}
}
}
cfg = {'system': {'jwt': {'expire': 604800, 'secret': ''}}}
os.environ['SYSTEM__JWT__EXPIRE'] = '86400'
os.environ['SYSTEM__JWT__SECRET'] = 'my_secret_key'
result = _apply_env_overrides_to_config(cfg)
assert result['system']['jwt']['expire'] == 86400
assert result['system']['jwt']['secret'] == 'my_secret_key'
del os.environ['SYSTEM__JWT__EXPIRE']
del os.environ['SYSTEM__JWT__SECRET']
def test_underscore_in_key(self):
"""Test keys with underscores like runtime_ws_url"""
cfg = {
'plugin': {
'enable': True,
'runtime_ws_url': 'ws://localhost:5400/control/ws'
}
}
cfg = {'plugin': {'enable': True, 'runtime_ws_url': 'ws://localhost:5400/control/ws'}}
os.environ['PLUGIN__RUNTIME_WS_URL'] = 'ws://newhost:6000/ws'
result = _apply_env_overrides_to_config(cfg)
assert result['plugin']['runtime_ws_url'] == 'ws://newhost:6000/ws'
del os.environ['PLUGIN__RUNTIME_WS_URL']
def test_boolean_conversion(self):
"""Test boolean value conversion"""
cfg = {
'plugin': {
'enable': True,
'enable_marketplace': False
}
}
cfg = {'plugin': {'enable': True, 'enable_marketplace': False}}
os.environ['PLUGIN__ENABLE'] = 'false'
os.environ['PLUGIN__ENABLE_MARKETPLACE'] = 'true'
result = _apply_env_overrides_to_config(cfg)
assert result['plugin']['enable'] is False
assert result['plugin']['enable_marketplace'] is True
del os.environ['PLUGIN__ENABLE']
del os.environ['PLUGIN__ENABLE_MARKETPLACE']
def test_ignore_dict_type(self):
"""Test that dict types are ignored"""
cfg = {
'database': {
'use': 'sqlite',
'sqlite': {
'path': 'data/langbot.db'
}
}
}
cfg = {'database': {'use': 'sqlite', 'sqlite': {'path': 'data/langbot.db'}}}
# Try to override a dict value - should be ignored
os.environ['DATABASE__SQLITE'] = 'new_value'
result = _apply_env_overrides_to_config(cfg)
# Should remain a dict, not overridden
assert isinstance(result['database']['sqlite'], dict)
assert result['database']['sqlite']['path'] == 'data/langbot.db'
del os.environ['DATABASE__SQLITE']
def test_ignore_list_type(self):
"""Test that list/array types are ignored"""
cfg = {
'admins': ['admin1', 'admin2'],
'command': {
'enable': True,
'prefix': ['!', '']
}
}
cfg = {'admins': ['admin1', 'admin2'], 'command': {'enable': True, 'prefix': ['!', '']}}
# Try to override list values - should be ignored
os.environ['ADMINS'] = 'admin3'
os.environ['COMMAND__PREFIX'] = '?'
result = _apply_env_overrides_to_config(cfg)
# Should remain lists, not overridden
assert isinstance(result['admins'], list)
assert result['admins'] == ['admin1', 'admin2']
assert isinstance(result['command']['prefix'], list)
assert result['command']['prefix'] == ['!', '']
del os.environ['ADMINS']
del os.environ['COMMAND__PREFIX']
def test_lowercase_env_var_ignored(self):
"""Test that lowercase environment variables are ignored"""
cfg = {
'api': {
'port': 5300
}
}
cfg = {'api': {'port': 5300}}
os.environ['api__port'] = '8080'
result = _apply_env_overrides_to_config(cfg)
# Should not be overridden
assert result['api']['port'] == 5300
del os.environ['api__port']
def test_no_double_underscore_ignored(self):
"""Test that env vars without __ are ignored"""
cfg = {
'api': {
'port': 5300
}
}
cfg = {'api': {'port': 5300}}
os.environ['APIPORT'] = '8080'
result = _apply_env_overrides_to_config(cfg)
# Should not be overridden
assert result['api']['port'] == 5300
del os.environ['APIPORT']
def test_nonexistent_key_ignored(self):
"""Test that env vars for non-existent keys are ignored"""
cfg = {
'api': {
'port': 5300
}
}
cfg = {'api': {'port': 5300}}
os.environ['API__NONEXISTENT'] = 'value'
result = _apply_env_overrides_to_config(cfg)
# Should not create new key
assert 'nonexistent' not in result['api']
del os.environ['API__NONEXISTENT']
def test_integer_conversion(self):
"""Test integer value conversion"""
cfg = {
'concurrency': {
'pipeline': 20
}
}
cfg = {'concurrency': {'pipeline': 20}}
os.environ['CONCURRENCY__PIPELINE'] = '100'
result = _apply_env_overrides_to_config(cfg)
assert result['concurrency']['pipeline'] == 100
assert isinstance(result['concurrency']['pipeline'], int)
del os.environ['CONCURRENCY__PIPELINE']
def test_multiple_overrides(self):
"""Test multiple environment variable overrides at once"""
cfg = {
'api': {
'port': 5300
},
'concurrency': {
'pipeline': 20,
'session': 1
},
'plugin': {
'enable': False
}
}
cfg = {'api': {'port': 5300}, 'concurrency': {'pipeline': 20, 'session': 1}, 'plugin': {'enable': False}}
os.environ['API__PORT'] = '8080'
os.environ['CONCURRENCY__PIPELINE'] = '50'
os.environ['PLUGIN__ENABLE'] = 'true'
result = _apply_env_overrides_to_config(cfg)
assert result['api']['port'] == 8080
assert result['concurrency']['pipeline'] == 50
assert result['plugin']['enable'] is True
del os.environ['API__PORT']
del os.environ['CONCURRENCY__PIPELINE']
del os.environ['PLUGIN__ENABLE']

View File

@@ -1,6 +1,5 @@
"""Test plugin list filtering by component kinds."""
from datetime import datetime
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -31,16 +30,7 @@ async def test_plugin_list_filter_by_component_kinds():
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'Tool',
'metadata': {'name': 'tool1'}
}
}
}
]
'components': [{'manifest': {'manifest': {'kind': 'Tool', 'metadata': {'name': 'tool1'}}}}],
},
{
'debug': False,
@@ -53,15 +43,8 @@ async def test_plugin_list_filter_by_component_kinds():
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'KnowledgeRetriever',
'metadata': {'name': 'retriever1'}
}
}
}
]
{'manifest': {'manifest': {'kind': 'KnowledgeRetriever', 'metadata': {'name': 'retriever1'}}}}
],
},
{
'debug': False,
@@ -73,16 +56,7 @@ async def test_plugin_list_filter_by_component_kinds():
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'Command',
'metadata': {'name': 'cmd1'}
}
}
}
]
'components': [{'manifest': {'manifest': {'kind': 'Command', 'metadata': {'name': 'cmd1'}}}}],
},
{
'debug': False,
@@ -94,16 +68,7 @@ async def test_plugin_list_filter_by_component_kinds():
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'EventListener',
'metadata': {'name': 'listener1'}
}
}
}
]
'components': [{'manifest': {'manifest': {'kind': 'EventListener', 'metadata': {'name': 'listener1'}}}}],
},
{
'debug': False,
@@ -116,23 +81,9 @@ async def test_plugin_list_filter_by_component_kinds():
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'KnowledgeRetriever',
'metadata': {'name': 'retriever2'}
}
}
},
{
'manifest': {
'manifest': {
'kind': 'Tool',
'metadata': {'name': 'tool2'}
}
}
}
]
{'manifest': {'manifest': {'kind': 'KnowledgeRetriever', 'metadata': {'name': 'retriever2'}}}},
{'manifest': {'manifest': {'kind': 'Tool', 'metadata': {'name': 'tool2'}}}},
],
},
]
@@ -187,16 +138,7 @@ async def test_plugin_list_filter_no_filter():
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'Tool',
'metadata': {'name': 'tool1'}
}
}
}
]
'components': [{'manifest': {'manifest': {'kind': 'Tool', 'metadata': {'name': 'tool1'}}}}],
},
{
'debug': False,
@@ -209,15 +151,8 @@ async def test_plugin_list_filter_no_filter():
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'KnowledgeRetriever',
'metadata': {'name': 'retriever1'}
}
}
}
]
{'manifest': {'manifest': {'kind': 'KnowledgeRetriever', 'metadata': {'name': 'retriever1'}}}}
],
},
]
@@ -267,15 +202,8 @@ async def test_plugin_list_filter_empty_result():
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'KnowledgeRetriever',
'metadata': {'name': 'retriever1'}
}
}
}
]
{'manifest': {'manifest': {'kind': 'KnowledgeRetriever', 'metadata': {'name': 'retriever1'}}}}
],
},
]
@@ -321,16 +249,7 @@ async def test_plugin_list_filter_plugin_without_components():
}
}
},
'components': [
{
'manifest': {
'manifest': {
'kind': 'Tool',
'metadata': {'name': 'tool1'}
}
}
}
]
'components': [{'manifest': {'manifest': {'kind': 'Tool', 'metadata': {'name': 'tool1'}}}}],
},
{
'debug': False,
@@ -342,7 +261,7 @@ async def test_plugin_list_filter_plugin_without_components():
}
}
},
'components': []
'components': [],
},
]

5791
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"]
}

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",

323
web/pnpm-lock.yaml generated
View File

@@ -99,14 +99,14 @@ dependencies:
specifier: ^1.4.2
version: 1.4.2(react-dom@19.2.1)(react@19.2.1)
lodash:
specifier: ^4.17.21
version: 4.17.21
specifier: ^4.17.23
version: 4.17.23
lucide-react:
specifier: ^0.507.0
version: 0.507.0(react@19.2.1)
next:
specifier: ~15.5.9
version: 15.5.9(react-dom@19.2.1)(react@19.2.1)
specifier: ~16.1.5
version: 16.1.5(react-dom@19.2.1)(react@19.2.1)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.1)(react@19.2.1)
@@ -137,6 +137,9 @@ dependencies:
react-syntax-highlighter:
specifier: ^16.1.0
version: 16.1.0(react@19.2.1)
recharts:
specifier: 2.15.4
version: 2.15.4(react-dom@19.2.1)(react@19.2.1)
rehype-autolink-headings:
specifier: ^7.1.0
version: 7.1.0
@@ -294,8 +297,8 @@ packages:
tslib: 2.8.1
dev: false
/@emnapi/core@1.7.1:
resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==}
/@emnapi/core@1.8.1:
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
requiresBuild: true
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -303,8 +306,8 @@ packages:
dev: true
optional: true
/@emnapi/runtime@1.7.1:
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
/@emnapi/runtime@1.8.1:
resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==}
requiresBuild: true
dependencies:
tslib: 2.8.1
@@ -656,7 +659,7 @@ packages:
cpu: [wasm32]
requiresBuild: true
dependencies:
'@emnapi/runtime': 1.7.1
'@emnapi/runtime': 1.8.1
dev: false
optional: true
@@ -721,14 +724,14 @@ packages:
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
requiresBuild: true
dependencies:
'@emnapi/core': 1.7.1
'@emnapi/runtime': 1.7.1
'@emnapi/core': 1.8.1
'@emnapi/runtime': 1.8.1
'@tybys/wasm-util': 0.10.1
dev: true
optional: true
/@next/env@15.5.9:
resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==}
/@next/env@16.1.5:
resolution: {integrity: sha512-CRSCPJiSZoi4Pn69RYBDI9R7YK2g59vLexPQFXY0eyw+ILevIenCywzg+DqmlBik9zszEnw2HLFOUlLAcJbL7g==}
dev: false
/@next/eslint-plugin-next@15.2.4:
@@ -737,8 +740,8 @@ packages:
fast-glob: 3.3.1
dev: true
/@next/swc-darwin-arm64@15.5.7:
resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==}
/@next/swc-darwin-arm64@16.1.5:
resolution: {integrity: sha512-eK7Wdm3Hjy/SCL7TevlH0C9chrpeOYWx2iR7guJDaz4zEQKWcS1IMVfMb9UKBFMg1XgzcPTYPIp1Vcpukkjg6Q==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
@@ -746,8 +749,8 @@ packages:
dev: false
optional: true
/@next/swc-darwin-x64@15.5.7:
resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==}
/@next/swc-darwin-x64@16.1.5:
resolution: {integrity: sha512-foQscSHD1dCuxBmGkbIr6ScAUF6pRoDZP6czajyvmXPAOFNnQUJu2Os1SGELODjKp/ULa4fulnBWoHV3XdPLfA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
@@ -755,8 +758,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-gnu@15.5.7:
resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==}
/@next/swc-linux-arm64-gnu@16.1.5:
resolution: {integrity: sha512-qNIb42o3C02ccIeSeKjacF3HXotGsxh/FMk/rSRmCzOVMtoWH88odn2uZqF8RLsSUWHcAqTgYmPD3pZ03L9ZAA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@@ -764,8 +767,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-arm64-musl@15.5.7:
resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==}
/@next/swc-linux-arm64-musl@16.1.5:
resolution: {integrity: sha512-U+kBxGUY1xMAzDTXmuVMfhaWUZQAwzRaHJ/I6ihtR5SbTVUEaDRiEU9YMjy1obBWpdOBuk1bcm+tsmifYSygfw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
@@ -773,8 +776,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-gnu@15.5.7:
resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==}
/@next/swc-linux-x64-gnu@16.1.5:
resolution: {integrity: sha512-gq2UtoCpN7Ke/7tKaU7i/1L7eFLfhMbXjNghSv0MVGF1dmuoaPeEVDvkDuO/9LVa44h5gqpWeJ4mRRznjDv7LA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@@ -782,8 +785,8 @@ packages:
dev: false
optional: true
/@next/swc-linux-x64-musl@15.5.7:
resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==}
/@next/swc-linux-x64-musl@16.1.5:
resolution: {integrity: sha512-bQWSE729PbXT6mMklWLf8dotislPle2L70E9q6iwETYEOt092GDn0c+TTNj26AjmeceSsC4ndyGsK5nKqHYXjQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
@@ -791,8 +794,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-arm64-msvc@15.5.7:
resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==}
/@next/swc-win32-arm64-msvc@16.1.5:
resolution: {integrity: sha512-LZli0anutkIllMtTAWZlDqdfvjWX/ch8AFK5WgkNTvaqwlouiD1oHM+WW8RXMiL0+vAkAJyAGEzPPjO+hnrSNQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
@@ -800,8 +803,8 @@ packages:
dev: false
optional: true
/@next/swc-win32-x64-msvc@15.5.7:
resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==}
/@next/swc-win32-x64-msvc@16.1.5:
resolution: {integrity: sha512-7is37HJTNQGhjPpQbkKjKEboHYQnCgpVt/4rBrrln0D9nderNxZ8ZWs8w1fAtzUx7wEyYjQ+/13myFgFj6K2Ng==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
@@ -2003,6 +2006,48 @@ packages:
dev: true
optional: true
/@types/d3-array@3.2.2:
resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==}
dev: false
/@types/d3-color@3.1.3:
resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==}
dev: false
/@types/d3-ease@3.0.2:
resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==}
dev: false
/@types/d3-interpolate@3.0.4:
resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==}
dependencies:
'@types/d3-color': 3.1.3
dev: false
/@types/d3-path@3.1.1:
resolution: {integrity: sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==}
dev: false
/@types/d3-scale@4.0.9:
resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==}
dependencies:
'@types/d3-time': 3.0.4
dev: false
/@types/d3-shape@3.1.7:
resolution: {integrity: sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==}
dependencies:
'@types/d3-path': 3.1.1
dev: false
/@types/d3-time@3.0.4:
resolution: {integrity: sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==}
dev: false
/@types/d3-timer@3.0.2:
resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==}
dev: false
/@types/debug@4.1.12:
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
dependencies:
@@ -2587,6 +2632,11 @@ packages:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
dev: true
/baseline-browser-mapping@2.9.19:
resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==}
hasBin: true
dev: false
/brace-expansion@1.1.12:
resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==}
dependencies:
@@ -2642,8 +2692,8 @@ packages:
engines: {node: '>=6'}
dev: false
/caniuse-lite@1.0.30001757:
resolution: {integrity: sha512-r0nnL/I28Zi/yjk1el6ilj27tKcdjLsNqAOZr0yVjWPrSQyHgKI2INaEWw21bAQSv2LXRt1XuCS/GomNpWOxsQ==}
/caniuse-lite@1.0.30001766:
resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==}
dev: false
/ccount@2.0.1:
@@ -2762,6 +2812,77 @@ packages:
/csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
/d3-array@3.2.4:
resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==}
engines: {node: '>=12'}
dependencies:
internmap: 2.0.3
dev: false
/d3-color@3.1.0:
resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==}
engines: {node: '>=12'}
dev: false
/d3-ease@3.0.1:
resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==}
engines: {node: '>=12'}
dev: false
/d3-format@3.1.0:
resolution: {integrity: sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==}
engines: {node: '>=12'}
dev: false
/d3-interpolate@3.0.1:
resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==}
engines: {node: '>=12'}
dependencies:
d3-color: 3.1.0
dev: false
/d3-path@3.1.0:
resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==}
engines: {node: '>=12'}
dev: false
/d3-scale@4.0.2:
resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==}
engines: {node: '>=12'}
dependencies:
d3-array: 3.2.4
d3-format: 3.1.0
d3-interpolate: 3.0.1
d3-time: 3.1.0
d3-time-format: 4.1.0
dev: false
/d3-shape@3.2.0:
resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==}
engines: {node: '>=12'}
dependencies:
d3-path: 3.1.0
dev: false
/d3-time-format@4.1.0:
resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==}
engines: {node: '>=12'}
dependencies:
d3-time: 3.1.0
dev: false
/d3-time@3.1.0:
resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==}
engines: {node: '>=12'}
dependencies:
d3-array: 3.2.4
dev: false
/d3-timer@3.0.1:
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
engines: {node: '>=12'}
dev: false
/damerau-levenshtein@1.0.8:
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
dev: true
@@ -2820,6 +2941,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: false
/decimal.js-light@2.5.1:
resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==}
dev: false
/decode-named-character-reference@1.2.0:
resolution: {integrity: sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==}
dependencies:
@@ -2884,6 +3009,13 @@ packages:
esutils: 2.0.3
dev: true
/dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dependencies:
'@babel/runtime': 7.28.4
csstype: 3.2.3
dev: false
/dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -3376,6 +3508,10 @@ packages:
engines: {node: '>=0.10.0'}
dev: true
/eventemitter3@4.0.7:
resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
dev: false
/eventemitter3@5.0.1:
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
dev: true
@@ -3407,6 +3543,11 @@ packages:
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
dev: true
/fast-equals@5.4.0:
resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
engines: {node: '>=6.0.0'}
dev: false
/fast-glob@3.3.1:
resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==}
engines: {node: '>=8.6.0'}
@@ -3898,6 +4039,11 @@ packages:
side-channel: 1.1.0
dev: true
/internmap@2.0.3:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
dev: false
/is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
dev: false
@@ -4160,7 +4306,6 @@ packages:
/js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
dev: true
/js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
@@ -4395,8 +4540,8 @@ packages:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
/lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
/lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
dev: false
/log-update@6.1.0:
@@ -4419,7 +4564,6 @@ packages:
hasBin: true
dependencies:
js-tokens: 4.0.0
dev: true
/lowlight@1.20.0:
resolution: {integrity: sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==}
@@ -4973,9 +5117,9 @@ packages:
react-dom: 19.2.1(react@19.2.1)
dev: false
/next@15.5.9(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
/next@16.1.5(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-f+wE+NSbiQgh3DSAlTaw2FwY5yGdVViAtp8TotNQj4kk4Q8Bh1sC/aL9aH+Rg1YAVn18OYXsRDT7U/079jgP7w==}
engines: {node: '>=20.9.0'}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
@@ -4994,22 +5138,23 @@ packages:
sass:
optional: true
dependencies:
'@next/env': 15.5.9
'@next/env': 16.1.5
'@swc/helpers': 0.5.15
caniuse-lite: 1.0.30001757
baseline-browser-mapping: 2.9.19
caniuse-lite: 1.0.30001766
postcss: 8.4.31
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
styled-jsx: 5.1.6(react@19.2.1)
optionalDependencies:
'@next/swc-darwin-arm64': 15.5.7
'@next/swc-darwin-x64': 15.5.7
'@next/swc-linux-arm64-gnu': 15.5.7
'@next/swc-linux-arm64-musl': 15.5.7
'@next/swc-linux-x64-gnu': 15.5.7
'@next/swc-linux-x64-musl': 15.5.7
'@next/swc-win32-arm64-msvc': 15.5.7
'@next/swc-win32-x64-msvc': 15.5.7
'@next/swc-darwin-arm64': 16.1.5
'@next/swc-darwin-x64': 16.1.5
'@next/swc-linux-arm64-gnu': 16.1.5
'@next/swc-linux-arm64-musl': 16.1.5
'@next/swc-linux-x64-gnu': 16.1.5
'@next/swc-linux-x64-musl': 16.1.5
'@next/swc-win32-arm64-msvc': 16.1.5
'@next/swc-win32-x64-msvc': 16.1.5
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
@@ -5026,7 +5171,6 @@ packages:
/object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
dev: true
/object-inspect@1.13.4:
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
@@ -5277,7 +5421,6 @@ packages:
loose-envify: 1.4.0
object-assign: 4.1.1
react-is: 16.13.1
dev: true
/property-information@6.5.0:
resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==}
@@ -5354,7 +5497,10 @@ packages:
/react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: true
/react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
dev: false
/react-markdown@10.1.0(@types/react@19.2.7)(react@19.2.1):
resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==}
@@ -5424,6 +5570,19 @@ packages:
use-sidecar: 1.1.3(@types/react@19.2.7)(react@19.2.1)
dev: false
/react-smooth@4.0.4(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
fast-equals: 5.4.0
prop-types: 15.8.1
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
react-transition-group: 4.4.5(react-dom@19.2.1)(react@19.2.1)
dev: false
/react-style-singleton@2.2.3(@types/react@19.2.7)(react@19.2.1):
resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==}
engines: {node: '>=10'}
@@ -5455,11 +5614,50 @@ packages:
refractor: 5.0.0
dev: false
/react-transition-group@4.4.5(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
react: '>=16.6.0'
react-dom: '>=16.6.0'
dependencies:
'@babel/runtime': 7.28.4
dom-helpers: 5.2.1
loose-envify: 1.4.0
prop-types: 15.8.1
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
dev: false
/react@19.2.1:
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
engines: {node: '>=0.10.0'}
dev: false
/recharts-scale@0.4.5:
resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==}
dependencies:
decimal.js-light: 2.5.1
dev: false
/recharts@2.15.4(react-dom@19.2.1)(react@19.2.1):
resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==}
engines: {node: '>=14'}
peerDependencies:
react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
clsx: 2.1.1
eventemitter3: 4.0.7
lodash: 4.17.23
react: 19.2.1
react-dom: 19.2.1(react@19.2.1)
react-is: 18.3.1
react-smooth: 4.0.4(react-dom@19.2.1)(react@19.2.1)
recharts-scale: 0.4.5
tiny-invariant: 1.3.3
victory-vendor: 36.9.2
dev: false
/reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'}
@@ -6040,6 +6238,10 @@ packages:
engines: {node: '>=6'}
dev: false
/tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
dev: false
/tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@@ -6318,6 +6520,25 @@ packages:
vfile-message: 4.0.3
dev: false
/victory-vendor@36.9.2:
resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==}
dependencies:
'@types/d3-array': 3.2.2
'@types/d3-ease': 3.0.2
'@types/d3-interpolate': 3.0.4
'@types/d3-scale': 4.0.9
'@types/d3-shape': 3.1.7
'@types/d3-time': 3.0.4
'@types/d3-timer': 3.0.2
d3-array: 3.2.4
d3-ease: 3.0.1
d3-interpolate: 3.0.1
d3-scale: 4.0.2
d3-shape: 3.2.0
d3-time: 3.1.0
d3-timer: 3.0.1
dev: false
/void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}

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,5 @@
import { LoadingPage } from '@/components/ui/loading-spinner';
export default function Loading() {
return <LoadingPage />;
}

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,11 +1,13 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useRouter } from 'next/navigation';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Sidebar,
SidebarContent,
@@ -45,6 +47,7 @@ export default function PipelineDialog({
onCancel,
}: PipelineDialogProps) {
const { t } = useTranslation();
const router = useRouter();
const [pipelineId, setPipelineId] = useState<string | undefined>(
propPipelineId,
);
@@ -190,23 +193,48 @@ export default function PipelineDialog({
>
<DialogTitle>{getDialogTitle()}</DialogTitle>
{currentMode === 'debug' && (
<div className="flex items-center gap-2 ml-2">
<div
className={`w-2.5 h-2.5 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
title={
isWebSocketConnected
<>
<div className="flex items-center gap-2 ml-2">
<div
className={`w-2.5 h-2.5 rounded-full ${
isWebSocketConnected ? 'bg-green-500' : 'bg-red-500'
}`}
title={
isWebSocketConnected
? t('pipelines.debugDialog.connected')
: t('pipelines.debugDialog.disconnected')
}
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
{isWebSocketConnected
? t('pipelines.debugDialog.connected')
: t('pipelines.debugDialog.disconnected')
}
/>
<span className="text-sm text-gray-600 dark:text-gray-400">
{isWebSocketConnected
? t('pipelines.debugDialog.connected')
: t('pipelines.debugDialog.disconnected')}
</span>
</div>
: t('pipelines.debugDialog.disconnected')}
</span>
</div>
<div className="ml-auto mr-4">
<Button
variant="outline"
size="sm"
onClick={() => {
router.push(
`/home/monitoring?pipelineId=${pipelineId}`,
);
onOpenChange(false);
}}
className="bg-white dark:bg-[#2a2a2e]"
>
<svg
className="w-4 h-4 mr-2"
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>
{t('monitoring.viewMonitoring')}
</Button>
</div>
</>
)}
</DialogHeader>
<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()}
</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

@@ -2,7 +2,7 @@ import { MCPServer, MCPSessionStatus } from '@/app/infra/entities/api';
export class MCPCardVO {
name: string;
mode: 'stdio' | 'sse';
mode: 'stdio' | 'sse' | 'http';
enable: boolean;
status: MCPSessionStatus;
tools: number;

Some files were not shown because too many files have changed in this diff Show More