From d9a630b8c1148e7194061ccf9d9f7a81fff972fe Mon Sep 17 00:00:00 2001 From: Junyan Chin Date: Wed, 25 Feb 2026 21:56:24 +0800 Subject: [PATCH] feat: add session message monitoring tab to bot detail dialog (#2005) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add session message monitoring tab to bot detail dialog Add a new "Sessions" tab in the bot detail dialog that displays sent & received messages grouped by sessions. Users can select any session to view its messages in a chat-bubble style layout. Backend changes: - Add sessionId filter to monitoring messages endpoint - Add role column to MonitoringMessage (user/assistant) - Record bot responses in monitoring via record_query_response() - Add DB migration (dbm019) for the new role column Frontend changes: - New BotSessionMonitor component with session list + message viewer - Add Sessions sidebar tab to BotDetailDialog - Add getBotSessions/getSessionMessages API methods to BackendClient - Add i18n translations (en-US, zh-Hans, zh-Hant, ja-JP) Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude Co-Authored-By: Happy * refactor: remove outdated version comment from PipelineManager class * fix: bump required_database_version to 19 to trigger monitoring_messages.role migration * fix: prevent session message auto-scroll from pushing dialog content out of view Replace scrollIntoView (which scrolls all ancestor containers) with direct scrollTop manipulation on the ScrollArea viewport. This keeps the scroll contained within the messages panel only. * ui: redesign BotSessionMonitor with polished chat UI - Wider session list (w-72) with avatar circles and cleaner layout - Richer chat header with avatar, platform info, and active indicator - User messages now use blue-500 (solid) instead of blue-100 for clear visual distinction - Metadata (time, runner) shown on hover below bubbles, not inside - Proper empty state illustrations for both panels - Better spacing, rounded corners, and shadow treatment - Consistent dark mode styling * fix: infinite re-render loop in DynamicFormComponent The useEffect depended on onSubmit which was a new closure every parent render. Calling onSubmit inside the effect triggered parent state update → re-render → new onSubmit ref → effect re-runs → loop. Fix: use useRef to hold a stable reference to onSubmit, removing it from the useEffect dependency array. Also add DialogDescription to BotDetailDialog to suppress Radix aria-describedby warning. * fix: remove .html suffix from docs.langbot.app links (Mintlify migration) * style: fix prettier and ruff formatting --------- Co-authored-by: Claude Co-authored-by: Happy --- README.md | 12 +- .../api/http/controller/groups/monitoring.py | 2 + .../pkg/api/http/service/monitoring.py | 5 + .../pkg/entity/persistence/monitoring.py | 1 + .../dbm019_monitoring_message_role.py | 24 + src/langbot/pkg/pipeline/monitoring_helper.py | 54 ++ src/langbot/pkg/pipeline/pipelinemgr.py | 16 +- src/langbot/pkg/utils/constants.py | 2 +- uv.lock | 10 +- web/src/app/home/bots/BotDetailDialog.tsx | 45 +- .../bot-session/BotSessionMonitor.tsx | 502 ++++++++++++++++++ .../dynamic-form/DynamicFormComponent.tsx | 11 +- web/src/app/infra/http/BackendClient.ts | 58 ++ web/src/i18n/locales/en-US.ts | 19 + web/src/i18n/locales/ja-JP.ts | 19 + web/src/i18n/locales/zh-Hans.ts | 19 + web/src/i18n/locales/zh-Hant.ts | 19 + 17 files changed, 798 insertions(+), 20 deletions(-) create mode 100644 src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py create mode 100644 web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx diff --git a/README.md b/README.md index 9080ffbe..03ec08ac 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ English / [简体中文](README_CN.md) / [繁體中文](README_TW.md) / [日本 [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers) Website | -Features | -Docs | -API | +Features | +Docs | +APIPlugin MarketRoadmap @@ -44,7 +44,7 @@ LangBot is an **open-source, production-grade platform** for building AI-powered - **Web Management Panel** — Configure, manage, and monitor your bots through an intuitive browser interface. No YAML editing required. - **Multi-Pipeline Architecture** — Different bots for different scenarios, with comprehensive monitoring and exception handling. -[→ Learn more about all features](https://docs.langbot.app/en/insight/features.html) +[→ Learn more about all features](https://docs.langbot.app/en/insight/features) --- @@ -71,7 +71,7 @@ docker compose up -d [![Deploy on Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/en-US/templates/ZKTBDH) [![Deploy on Railway](https://railway.com/button.svg)](https://railway.app/template/yRrAyL?referralCode=vogKPF) -**More options:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker.html) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual.html) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt.html) · [Kubernetes](./docker/README_K8S.md) +**More options:** [Docker](https://docs.langbot.app/en/deploy/langbot/docker) · [Manual](https://docs.langbot.app/en/deploy/langbot/manual) · [BTPanel](https://docs.langbot.app/en/deploy/langbot/one-click/bt) · [Kubernetes](./docker/README_K8S.md) --- @@ -119,7 +119,7 @@ docker compose up -d | [接口 AI](https://jiekou.ai/) | Gateway | ✅ | | [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ | -[→ View all integrations](https://docs.langbot.app/en/insight/features.html) +[→ View all integrations](https://docs.langbot.app/en/insight/features) --- diff --git a/src/langbot/pkg/api/http/controller/groups/monitoring.py b/src/langbot/pkg/api/http/controller/groups/monitoring.py index 3b9f1e08..3cf08b7c 100644 --- a/src/langbot/pkg/api/http/controller/groups/monitoring.py +++ b/src/langbot/pkg/api/http/controller/groups/monitoring.py @@ -52,6 +52,7 @@ class MonitoringRouterGroup(group.RouterGroup): # Parse query parameters bot_ids = quart.request.args.getlist('botId') pipeline_ids = quart.request.args.getlist('pipelineId') + session_ids = quart.request.args.getlist('sessionId') start_time_str = quart.request.args.get('startTime') end_time_str = quart.request.args.get('endTime') limit = int(quart.request.args.get('limit', 100)) @@ -64,6 +65,7 @@ class MonitoringRouterGroup(group.RouterGroup): 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, + session_ids=session_ids if session_ids else None, start_time=start_time, end_time=end_time, limit=limit, diff --git a/src/langbot/pkg/api/http/service/monitoring.py b/src/langbot/pkg/api/http/service/monitoring.py index b9983519..886b4ccc 100644 --- a/src/langbot/pkg/api/http/service/monitoring.py +++ b/src/langbot/pkg/api/http/service/monitoring.py @@ -32,6 +32,7 @@ class MonitoringService: user_id: str | None = None, runner_name: str | None = None, variables: str | None = None, + role: str = 'user', ) -> str: """Record a message""" message_id = str(uuid.uuid4()) @@ -50,6 +51,7 @@ class MonitoringService: 'user_id': user_id, 'runner_name': runner_name, 'variables': variables, + 'role': role, } await self.ap.persistence_mgr.execute_async( @@ -355,6 +357,7 @@ class MonitoringService: self, bot_ids: list[str] | None = None, pipeline_ids: list[str] | None = None, + session_ids: list[str] | None = None, start_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None, limit: int = 100, @@ -367,6 +370,8 @@ class MonitoringService: conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids)) if pipeline_ids: conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(pipeline_ids)) + if session_ids: + conditions.append(persistence_monitoring.MonitoringMessage.session_id.in_(session_ids)) if start_time: conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time) if end_time: diff --git a/src/langbot/pkg/entity/persistence/monitoring.py b/src/langbot/pkg/entity/persistence/monitoring.py index 62121de6..82d8ece5 100644 --- a/src/langbot/pkg/entity/persistence/monitoring.py +++ b/src/langbot/pkg/entity/persistence/monitoring.py @@ -22,6 +22,7 @@ class MonitoringMessage(Base): 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 + role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant class MonitoringLLMCall(Base): diff --git a/src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py b/src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py new file mode 100644 index 00000000..b1372aa7 --- /dev/null +++ b/src/langbot/pkg/persistence/migrations/dbm019_monitoring_message_role.py @@ -0,0 +1,24 @@ +import sqlalchemy +from .. import migration + + +@migration.migration_class(19) +class DBMigrateMonitoringMessageRole(migration.DBMigration): + """Add role column to monitoring_messages table""" + + async def upgrade(self): + """Upgrade""" + try: + sql_text = sqlalchemy.text("ALTER TABLE monitoring_messages ADD COLUMN role VARCHAR(50) DEFAULT 'user'") + await self.ap.persistence_mgr.execute_async(sql_text) + except Exception: + # Column may already exist + pass + + async def downgrade(self): + """Downgrade""" + try: + sql_text = sqlalchemy.text('ALTER TABLE monitoring_messages DROP COLUMN role') + await self.ap.persistence_mgr.execute_async(sql_text) + except Exception: + pass diff --git a/src/langbot/pkg/pipeline/monitoring_helper.py b/src/langbot/pkg/pipeline/monitoring_helper.py index e5289934..6ad9a30d 100644 --- a/src/langbot/pkg/pipeline/monitoring_helper.py +++ b/src/langbot/pkg/pipeline/monitoring_helper.py @@ -114,6 +114,60 @@ class MonitoringHelper: except Exception as e: ap.logger.error(f'Failed to record query success: {e}') + @staticmethod + async def record_query_response( + ap: app.Application, + query: pipeline_query.Query, + bot_id: str, + bot_name: str, + pipeline_id: str, + pipeline_name: str, + runner_name: str | None = None, + ): + """Record bot response message to monitoring""" + try: + session_id = f'{query.launcher_type}_{query.launcher_id}' + + # Extract response content from resp_message_chain + if hasattr(query, 'resp_message_chain') and query.resp_message_chain: + # Serialize the last response message chain + last_resp = query.resp_message_chain[-1] + if hasattr(last_resp, 'model_dump'): + message_content = json.dumps(last_resp.model_dump(), ensure_ascii=False) + else: + message_content = str(last_resp) + elif hasattr(query, 'resp_messages') and query.resp_messages: + last_resp = query.resp_messages[-1] + if hasattr(last_resp, 'get_content_platform_message_chain'): + chain = last_resp.get_content_platform_message_chain() + if hasattr(chain, 'model_dump'): + message_content = json.dumps(chain.model_dump(), ensure_ascii=False) + else: + message_content = str(chain) + else: + message_content = str(last_resp) + else: + return # No response to record + + 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='success', + 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, + role='assistant', + ) + except Exception as e: + ap.logger.error(f'Failed to record query response: {e}') + @staticmethod async def record_query_error( ap: app.Application, diff --git a/src/langbot/pkg/pipeline/pipelinemgr.py b/src/langbot/pkg/pipeline/pipelinemgr.py index 08c449a7..d56f626c 100644 --- a/src/langbot/pkg/pipeline/pipelinemgr.py +++ b/src/langbot/pkg/pipeline/pipelinemgr.py @@ -339,6 +339,20 @@ class RuntimePipeline: except Exception as e: self.ap.logger.error(f'Failed to record query success: {e}') + # Record bot response message + try: + await monitoring_helper.MonitoringHelper.record_query_response( + 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, + ) + except Exception as e: + self.ap.logger.error(f'Failed to record query response: {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}') @@ -369,8 +383,6 @@ class RuntimePipeline: class PipelineManager: """流水线管理器""" - # ====== 4.0 ====== - ap: app.Application pipelines: list[RuntimePipeline] diff --git a/src/langbot/pkg/utils/constants.py b/src/langbot/pkg/utils/constants.py index 6e66b989..393012c1 100644 --- a/src/langbot/pkg/utils/constants.py +++ b/src/langbot/pkg/utils/constants.py @@ -2,7 +2,7 @@ import langbot semantic_version = f'v{langbot.__version__}' -required_database_version = 18 +required_database_version = 19 """Tag the version of the database schema, used to check if the database needs to be migrated""" debug_mode = False diff --git a/uv.lock b/uv.lock index ac0e47f5..fef488bd 100644 --- a/uv.lock +++ b/uv.lock @@ -1799,7 +1799,7 @@ wheels = [ [[package]] name = "langbot" -version = "4.8.3" +version = "4.8.4" source = { editable = "." } dependencies = [ { name = "aiocqhttp" }, @@ -1902,7 +1902,7 @@ requires-dist = [ { name = "ebooklib", specifier = ">=0.18" }, { name = "gewechat-client", specifier = ">=0.1.5" }, { name = "html2text", specifier = ">=2024.2.26" }, - { name = "langbot-plugin", specifier = "==0.2.5" }, + { name = "langbot-plugin", specifier = "==0.2.7" }, { name = "langchain", specifier = ">=0.2.0" }, { name = "langchain-text-splitters", specifier = ">=0.0.1" }, { name = "lark-oapi", specifier = ">=1.4.15" }, @@ -1958,7 +1958,7 @@ dev = [ [[package]] name = "langbot-plugin" -version = "0.2.5" +version = "0.2.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiofiles" }, @@ -1976,9 +1976,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/28/0e/117dfc00f36323cce2369be5176d5cd5247ff52edb34791413af9623f290/langbot_plugin-0.2.5.tar.gz", hash = "sha256:a1bf04c1c07b30c72fb9b28e1330372bb4a43ae2db309394435fc088c513cfd5", size = 103910, upload-time = "2026-01-29T13:55:34.328Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/a0/babd76596e5de38149da67b8da20e0519cc5f10080de9dc2b16919486f29/langbot_plugin-0.2.7.tar.gz", hash = "sha256:5c8ad1820283901a33356f79a56c84b4744712a463e1c7aecc6e9defe4db4446", size = 162458, upload-time = "2026-02-25T06:00:52.512Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/0e/19b9a427206fa46aafbff03437296e38f425365c9ea6a97cbcfa791da2f8/langbot_plugin-0.2.5-py3-none-any.whl", hash = "sha256:b784248fc1f4754cd143bd9a16a7abd89a5c9735a4aa2b03c1c1e771b7d361e9", size = 133362, upload-time = "2026-01-29T13:55:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/6575cf5d5babb7a9400a8aca243e4b8341d83b673e5e9c0394c0393f1c3e/langbot_plugin-0.2.7-py3-none-any.whl", hash = "sha256:17344e61537a5bb97fc77cd83812b5db926f29005e92fefbcbaca5bb47bf55f0", size = 133476, upload-time = "2026-02-25T06:00:50.988Z" }, ] [[package]] diff --git a/web/src/app/home/bots/BotDetailDialog.tsx b/web/src/app/home/bots/BotDetailDialog.tsx index fbb8c359..55c66fa7 100644 --- a/web/src/app/home/bots/BotDetailDialog.tsx +++ b/web/src/app/home/bots/BotDetailDialog.tsx @@ -5,6 +5,7 @@ import { Dialog, DialogContent, DialogHeader, + DialogDescription, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; @@ -21,6 +22,7 @@ import { import { Button } from '@/components/ui/button'; import BotForm from '@/app/home/bots/components/bot-form/BotForm'; import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent'; +import BotSessionMonitor from '@/app/home/bots/components/bot-session/BotSessionMonitor'; import { useTranslation } from 'react-i18next'; import { z } from 'zod'; import { httpClient } from '@/app/infra/http/HttpClient'; @@ -82,6 +84,19 @@ export default function BotDetailDialog({ ), }, + { + key: 'sessions', + label: t('bots.sessionMonitor.title'), + icon: ( + + + + ), + }, ]; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -122,6 +137,9 @@ export default function BotDetailDialog({
{t('bots.createBot')} + + {t('bots.createBot')} +
- + {activeMenu === 'config' ? t('bots.editBot') - : t('bots.botLogTitle')} + : activeMenu === 'logs' + ? t('bots.botLogTitle') + : t('bots.sessionMonitor.title')} + + {activeMenu === 'config' + ? t('bots.editBot') + : activeMenu === 'logs' + ? t('bots.botLogTitle') + : t('bots.sessionMonitor.title')} + -
+
{activeMenu === 'config' && ( )} + {activeMenu === 'sessions' && botId && ( + + )}
{activeMenu === 'config' && ( @@ -238,6 +274,9 @@ export default function BotDetailDialog({ {t('common.confirmDelete')} + + {t('bots.deleteConfirmation')} +
{t('bots.deleteConfirmation')}
diff --git a/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx new file mode 100644 index 00000000..f3c8e19e --- /dev/null +++ b/web/src/app/home/bots/components/bot-session/BotSessionMonitor.tsx @@ -0,0 +1,502 @@ +'use client'; + +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { httpClient } from '@/app/infra/http/HttpClient'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import { + MessageChainComponent, + Plain, + At, + Image, + Quote, + Voice, +} from '@/app/infra/entities/message'; + +interface SessionInfo { + session_id: string; + bot_id: string; + bot_name: string; + pipeline_id: string; + pipeline_name: string; + message_count: number; + start_time: string; + last_activity: string; + is_active: boolean; + platform?: string | null; + user_id?: string | null; +} + +interface SessionMessage { + 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 | null; + user_id?: string | null; + runner_name?: string | null; + variables?: string | null; + role?: string | null; +} + +interface BotSessionMonitorProps { + botId: string; +} + +export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) { + const { t } = useTranslation(); + const [sessions, setSessions] = useState([]); + const [selectedSessionId, setSelectedSessionId] = useState( + null, + ); + const [messages, setMessages] = useState([]); + const [loadingSessions, setLoadingSessions] = useState(false); + const [loadingMessages, setLoadingMessages] = useState(false); + const messagesContainerRef = useRef(null); + + const loadSessions = useCallback(async () => { + setLoadingSessions(true); + try { + const response = await httpClient.getBotSessions(botId); + setSessions(response.sessions ?? []); + } catch (error) { + console.error('Failed to load sessions:', error); + } finally { + setLoadingSessions(false); + } + }, [botId]); + + const loadMessages = useCallback(async (sessionId: string) => { + setLoadingMessages(true); + try { + const response = await httpClient.getSessionMessages(sessionId); + const sorted = (response.messages ?? []).sort( + (a, b) => + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(), + ); + setMessages(sorted); + } catch (error) { + console.error('Failed to load session messages:', error); + } finally { + setLoadingMessages(false); + } + }, []); + + useEffect(() => { + loadSessions(); + }, [loadSessions]); + + useEffect(() => { + if (selectedSessionId) { + loadMessages(selectedSessionId); + } else { + setMessages([]); + } + }, [selectedSessionId, loadMessages]); + + useEffect(() => { + const container = messagesContainerRef.current; + if (container) { + const viewport = container.querySelector( + '[data-radix-scroll-area-viewport]', + ); + const scrollTarget = viewport || container; + scrollTarget.scrollTop = scrollTarget.scrollHeight; + } + }, [messages]); + + const parseMessageChain = (content: string): MessageChainComponent[] => { + try { + const parsed = JSON.parse(content); + if (Array.isArray(parsed)) { + return parsed as MessageChainComponent[]; + } + } catch { + // Not JSON, return as plain text + } + return [{ type: 'Plain', text: content } as Plain]; + }; + + const isUserMessage = (msg: SessionMessage): boolean => { + if (msg.role === 'assistant') return false; + if (msg.role === 'user') return true; + return !msg.runner_name; + }; + + const renderMessageComponent = ( + component: MessageChainComponent, + index: number, + ) => { + switch (component.type) { + case 'Plain': + return {(component as Plain).text}; + + case 'At': { + const atComponent = component as At; + const displayName = + atComponent.display || atComponent.target?.toString() || ''; + return ( + + @{displayName} + + ); + } + + case 'AtAll': + return ( + + @All + + ); + + case 'Image': { + const img = component as Image; + const imageUrl = img.url || (img.base64 ? img.base64 : ''); + if (!imageUrl) { + return ( + + [Image] + + ); + } + return ( +
+ Image +
+ ); + } + + case 'Voice': { + const voice = component as Voice; + const voiceUrl = voice.url || (voice.base64 ? voice.base64 : ''); + if (!voiceUrl) { + return ( + + 🎙 [Voice] + + ); + } + return ( +
+
+ ); + } + + case 'Quote': { + const quote = component as Quote; + return ( +
+
+ {quote.origin?.map((comp, idx) => + renderMessageComponent(comp as MessageChainComponent, idx), + )} +
+
+ ); + } + + case 'Source': + return null; + + case 'File': { + const file = component as MessageChainComponent & { name?: string }; + return ( + + 📎 {file.name || 'File'} + + ); + } + + default: + return ( + + [{component.type}] + + ); + } + }; + + const renderMessageContent = (msg: SessionMessage) => { + const chain = parseMessageChain(msg.message_content); + return ( +
+ {chain.map((component, index) => + renderMessageComponent(component, index), + )} +
+ ); + }; + + const formatTime = (timestamp: string): string => { + if (!timestamp) return ''; + const date = new Date(timestamp); + const hours = date.getHours().toString().padStart(2, '0'); + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${hours}:${minutes}`; + }; + + const formatRelativeTime = (timestamp: string): string => { + if (!timestamp) return ''; + const date = new Date(timestamp); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMins = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMins < 1) return '<1m'; + if (diffMins < 60) return `${diffMins}m`; + if (diffHours < 24) return `${diffHours}h`; + return `${diffDays}d`; + }; + + const selectedSession = sessions.find( + (s) => s.session_id === selectedSessionId, + ); + + return ( +
+ {/* Left Panel: Session List */} +
+ {/* Refresh Button */} +
+ +
+ + {/* Session List */} + + {loadingSessions && sessions.length === 0 ? ( +
+ {t('bots.sessionMonitor.loading')} +
+ ) : sessions.length === 0 ? ( +
+ {t('bots.sessionMonitor.noSessions')} +
+ ) : ( +
+ {sessions.map((session) => { + const isSelected = selectedSessionId === session.session_id; + return ( + + ); + })} +
+ )} +
+
+ + {/* Right Panel: Messages */} +
+ {!selectedSessionId ? ( +
+ {t('bots.sessionMonitor.selectSession')} +
+ ) : ( + <> + {/* Chat Header */} +
+
+
+ {selectedSession?.user_id || selectedSessionId.slice(0, 20)} +
+
+ {selectedSession?.platform && ( + {selectedSession.platform} + )} + {selectedSession?.pipeline_name && ( + <> + {selectedSession?.platform && ·} + {selectedSession.pipeline_name} + + )} + {selectedSession?.is_active && ( + <> + · + + + Active + + + )} +
+
+ +
+ + {/* Messages Area — matches DebugDialog style */} + +
+ {loadingMessages ? ( +
+ {t('bots.sessionMonitor.loading')} +
+ ) : messages.length === 0 ? ( +
+ {t('bots.sessionMonitor.noMessages')} +
+ ) : ( + messages.map((msg) => { + const isUser = isUserMessage(msg); + return ( +
+
+ {renderMessageContent(msg)} + {/* Role label + timestamp inside bubble, matching DebugDialog */} +
+ + {isUser + ? t('bots.sessionMonitor.userMessage', { + defaultValue: 'User', + }) + : t('bots.sessionMonitor.botMessage', { + defaultValue: 'Assistant', + })} + + + {formatTime(msg.timestamp)} + + {msg.status === 'error' && ( + error + )} + {msg.runner_name && ( + + {msg.runner_name} + + )} +
+
+
+ ); + }) + )} +
+
+ + )} +
+
+ ); +} diff --git a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx index 7570a084..4d9532eb 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormComponent.tsx @@ -141,6 +141,11 @@ export default function DynamicFormComponent({ } }, [initialValues, form, itemConfigList]); + // Stable ref for onSubmit to avoid re-triggering the effect when the + // parent passes a new closure on every render. + const onSubmitRef = useRef(onSubmit); + onSubmitRef.current = onSubmit; + // 监听表单值变化 useEffect(() => { // Emit initial form values immediately so the parent always has a valid snapshot, @@ -154,7 +159,7 @@ export default function DynamicFormComponent({ }, {} as Record, ); - onSubmit?.(initialFinalValues); + onSubmitRef.current?.(initialFinalValues); const subscription = form.watch(() => { const formValues = form.getValues(); @@ -165,10 +170,10 @@ export default function DynamicFormComponent({ }, {} as Record, ); - onSubmit?.(finalValues); + onSubmitRef.current?.(finalValues); }); return () => subscription.unsubscribe(); - }, [form, onSubmit, itemConfigList]); + }, [form, itemConfigList]); return (
diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts index bb913bd0..22838676 100644 --- a/web/src/app/infra/http/BackendClient.ts +++ b/web/src/app/infra/http/BackendClient.ts @@ -339,6 +339,64 @@ export class BackendClient extends BaseHttpClient { return this.post(`/api/v1/platform/bots/${botId}/logs`, request); } + public getBotSessions( + botId: string, + limit: number = 100, + offset: number = 0, + ): Promise<{ + sessions: Array<{ + session_id: string; + bot_id: string; + bot_name: string; + pipeline_id: string; + pipeline_name: string; + message_count: number; + start_time: string; + last_activity: string; + is_active: boolean; + platform: string | null; + user_id: string | null; + }>; + total: number; + }> { + const queryParams = new URLSearchParams(); + queryParams.append('botId', botId); + queryParams.append('limit', limit.toString()); + queryParams.append('offset', offset.toString()); + return this.get(`/api/v1/monitoring/sessions?${queryParams.toString()}`); + } + + public getSessionMessages( + sessionId: string, + limit: number = 200, + offset: number = 0, + ): Promise<{ + messages: Array<{ + id: string; + timestamp: string; + bot_id: string; + bot_name: string; + pipeline_id: string; + pipeline_name: string; + message_content: string; + session_id: string; + status: string; + level: string; + platform: string | null; + user_id: string | null; + runner_name: string | null; + variables: string | null; + role: string | null; + }>; + total: number; + }> { + const queryParams = new URLSearchParams(); + queryParams.append('sessionId', sessionId); + queryParams.append('limit', limit.toString()); + queryParams.append('offset', offset.toString()); + return this.get(`/api/v1/monitoring/messages?${queryParams.toString()}`); + } + // ============ File management API ============ public uploadDocumentFile(file: File): Promise<{ file_id: string }> { const formData = new FormData(); diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index a8faed14..56a5855b 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -280,6 +280,25 @@ const enUS = { viewDetails: 'Details', collapse: 'Collapse', imagesAttached: 'image(s) attached', + sessionMonitor: { + title: 'Sessions', + sessions: 'Sessions', + noSessions: 'No sessions found', + selectSession: 'Select a session to view messages', + noMessages: 'No messages in this session', + messages: 'messages', + messageCount: '{{count}} messages', + loading: 'Loading...', + loadingSessions: 'Loading sessions...', + loadingMessages: 'Loading messages...', + user: 'User', + variables: 'Variables', + platform: 'Platform', + lastActive: 'Last active', + refresh: 'Refresh', + active: 'Active', + inactive: 'Inactive', + }, }, plugins: { title: 'Extensions', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 7166aa07..9f076e1a 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -281,6 +281,25 @@ const jaJP = { allLevels: 'すべてのレベル', selectLevel: 'レベルを選択', levelsSelected: 'レベル選択済み', + sessionMonitor: { + title: 'セッション監視', + sessions: 'セッション一覧', + noSessions: 'セッションが見つかりません', + selectSession: 'セッションを選択してメッセージを表示', + noMessages: 'このセッションにはメッセージがありません', + messages: '件のメッセージ', + messageCount: '{{count}} 件のメッセージ', + loading: '読み込み中...', + loadingSessions: 'セッションを読み込み中...', + loadingMessages: 'メッセージを読み込み中...', + user: 'ユーザー', + variables: '変数', + platform: 'プラットフォーム', + lastActive: '最終アクティブ', + refresh: '更新', + active: 'アクティブ', + inactive: '非アクティブ', + }, }, plugins: { title: '拡張機能', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 9e3c577e..74f0cd08 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -269,6 +269,25 @@ const zhHans = { viewDetails: '详情', collapse: '收起', imagesAttached: '张图片', + sessionMonitor: { + title: '会话监控', + sessions: '会话列表', + noSessions: '暂无会话', + selectSession: '选择一个会话查看消息', + noMessages: '该会话暂无消息', + messages: '条消息', + messageCount: '{{count}} 条消息', + loading: '加载中...', + loadingSessions: '加载会话中...', + loadingMessages: '加载消息中...', + user: '用户', + variables: '变量', + platform: '平台', + lastActive: '最近活跃', + refresh: '刷新', + active: '活跃', + inactive: '不活跃', + }, }, plugins: { title: '插件扩展', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 94a0a380..1f937264 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -264,6 +264,25 @@ const zhHant = { allLevels: '全部級別', selectLevel: '選擇級別', levelsSelected: '個級別已選', + sessionMonitor: { + title: '會話監控', + sessions: '會話列表', + noSessions: '暫無會話', + selectSession: '選擇一個會話查看訊息', + noMessages: '該會話暫無訊息', + messages: '條訊息', + messageCount: '{{count}} 條訊息', + loading: '載入中...', + loadingSessions: '載入會話中...', + loadingMessages: '載入訊息中...', + user: '用戶', + variables: '變數', + platform: '平台', + lastActive: '最近活躍', + refresh: '重新整理', + active: '活躍', + inactive: '不活躍', + }, }, plugins: { title: '外掛擴展',