feat: add session message monitoring tab to bot detail dialog (#2005)

* 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 <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

* 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 <noreply@anthropic.com>
Co-authored-by: Happy <yesreply@happy.engineering>
This commit is contained in:
Junyan Chin
2026-02-25 21:56:24 +08:00
committed by GitHub
parent b8df0dbd7f
commit d9a630b8c1
17 changed files with 798 additions and 20 deletions

View File

@@ -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) [![GitHub stars](https://img.shields.io/github/stars/langbot-app/LangBot?style=social)](https://github.com/langbot-app/LangBot/stargazers)
<a href="https://langbot.app">Website</a> <a href="https://langbot.app">Website</a>
<a href="https://docs.langbot.app/en/insight/features.html">Features</a> <a href="https://docs.langbot.app/en/insight/features">Features</a>
<a href="https://docs.langbot.app/en/insight/guide.html">Docs</a> <a href="https://docs.langbot.app/en/insight/guide">Docs</a>
<a href="https://docs.langbot.app/en/tags/readme.html">API</a> <a href="https://docs.langbot.app/en/tags/readme">API</a>
<a href="https://space.langbot.app">Plugin Market</a> <a href="https://space.langbot.app">Plugin Market</a>
<a href="https://langbot.featurebase.app/roadmap">Roadmap</a> <a href="https://langbot.featurebase.app/roadmap">Roadmap</a>
@@ -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. - **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. - **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 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) [![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 | ✅ | | [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 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)
--- ---

View File

@@ -52,6 +52,7 @@ class MonitoringRouterGroup(group.RouterGroup):
# Parse query parameters # Parse query parameters
bot_ids = quart.request.args.getlist('botId') bot_ids = quart.request.args.getlist('botId')
pipeline_ids = quart.request.args.getlist('pipelineId') pipeline_ids = quart.request.args.getlist('pipelineId')
session_ids = quart.request.args.getlist('sessionId')
start_time_str = quart.request.args.get('startTime') start_time_str = quart.request.args.get('startTime')
end_time_str = quart.request.args.get('endTime') end_time_str = quart.request.args.get('endTime')
limit = int(quart.request.args.get('limit', 100)) 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( messages, total = await self.ap.monitoring_service.get_messages(
bot_ids=bot_ids if bot_ids else None, bot_ids=bot_ids if bot_ids else None,
pipeline_ids=pipeline_ids if pipeline_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, start_time=start_time,
end_time=end_time, end_time=end_time,
limit=limit, limit=limit,

View File

@@ -32,6 +32,7 @@ class MonitoringService:
user_id: str | None = None, user_id: str | None = None,
runner_name: str | None = None, runner_name: str | None = None,
variables: str | None = None, variables: str | None = None,
role: str = 'user',
) -> str: ) -> str:
"""Record a message""" """Record a message"""
message_id = str(uuid.uuid4()) message_id = str(uuid.uuid4())
@@ -50,6 +51,7 @@ class MonitoringService:
'user_id': user_id, 'user_id': user_id,
'runner_name': runner_name, 'runner_name': runner_name,
'variables': variables, 'variables': variables,
'role': role,
} }
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
@@ -355,6 +357,7 @@ class MonitoringService:
self, self,
bot_ids: list[str] | None = None, bot_ids: list[str] | None = None,
pipeline_ids: list[str] | None = None, pipeline_ids: list[str] | None = None,
session_ids: list[str] | None = None,
start_time: datetime.datetime | None = None, start_time: datetime.datetime | None = None,
end_time: datetime.datetime | None = None, end_time: datetime.datetime | None = None,
limit: int = 100, limit: int = 100,
@@ -367,6 +370,8 @@ class MonitoringService:
conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids)) conditions.append(persistence_monitoring.MonitoringMessage.bot_id.in_(bot_ids))
if pipeline_ids: if pipeline_ids:
conditions.append(persistence_monitoring.MonitoringMessage.pipeline_id.in_(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: if start_time:
conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time) conditions.append(persistence_monitoring.MonitoringMessage.timestamp >= start_time)
if end_time: if end_time:

View File

@@ -22,6 +22,7 @@ class MonitoringMessage(Base):
user_id = 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 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 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): class MonitoringLLMCall(Base):

View File

@@ -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

View File

@@ -114,6 +114,60 @@ class MonitoringHelper:
except Exception as e: except Exception as e:
ap.logger.error(f'Failed to record query success: {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 @staticmethod
async def record_query_error( async def record_query_error(
ap: app.Application, ap: app.Application,

View File

@@ -339,6 +339,20 @@ class RuntimePipeline:
except Exception as e: except Exception as e:
self.ap.logger.error(f'Failed to record query success: {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: except Exception as e:
inst_name = query.current_stage_name if query.current_stage_name else 'unknown' 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'Error processing query {query.query_id} stage={inst_name} : {e}')
@@ -369,8 +383,6 @@ class RuntimePipeline:
class PipelineManager: class PipelineManager:
"""流水线管理器""" """流水线管理器"""
# ====== 4.0 ======
ap: app.Application ap: app.Application
pipelines: list[RuntimePipeline] pipelines: list[RuntimePipeline]

View File

@@ -2,7 +2,7 @@ import langbot
semantic_version = f'v{langbot.__version__}' 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""" """Tag the version of the database schema, used to check if the database needs to be migrated"""
debug_mode = False debug_mode = False

10
uv.lock generated
View File

@@ -1799,7 +1799,7 @@ wheels = [
[[package]] [[package]]
name = "langbot" name = "langbot"
version = "4.8.3" version = "4.8.4"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },
@@ -1902,7 +1902,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { 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", specifier = ">=0.2.0" },
{ name = "langchain-text-splitters", specifier = ">=0.0.1" }, { name = "langchain-text-splitters", specifier = ">=0.0.1" },
{ name = "lark-oapi", specifier = ">=1.4.15" }, { name = "lark-oapi", specifier = ">=1.4.15" },
@@ -1958,7 +1958,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.2.5" version = "0.2.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
@@ -1976,9 +1976,9 @@ dependencies = [
{ name = "watchdog" }, { name = "watchdog" },
{ name = "websockets" }, { 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 = [ 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]] [[package]]

View File

@@ -5,6 +5,7 @@ import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogDescription,
DialogTitle, DialogTitle,
DialogFooter, DialogFooter,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
@@ -21,6 +22,7 @@ import {
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import BotForm from '@/app/home/bots/components/bot-form/BotForm'; import BotForm from '@/app/home/bots/components/bot-form/BotForm';
import { BotLogListComponent } from '@/app/home/bots/components/bot-log/view/BotLogListComponent'; 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 { useTranslation } from 'react-i18next';
import { z } from 'zod'; import { z } from 'zod';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
@@ -82,6 +84,19 @@ export default function BotDetailDialog({
</svg> </svg>
), ),
}, },
{
key: 'sessions',
label: t('bots.sessionMonitor.title'),
icon: (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
>
<path d="M2 22C2 17.5817 5.58172 14 10 14C14.4183 14 18 17.5817 18 22H16C16 18.6863 13.3137 16 10 16C6.68629 16 4 18.6863 4 22H2ZM10 13C6.685 13 4 10.315 4 7C4 3.685 6.685 1 10 1C13.315 1 16 3.685 16 7C16 10.315 13.315 13 10 13ZM10 11C12.21 11 14 9.21 14 7C14 4.79 12.21 3 10 3C7.79 3 6 4.79 6 7C6 9.21 7.79 11 10 11ZM18.2837 14.7028C21.0644 15.9561 23 18.752 23 22H21C21 19.564 19.5483 17.4671 17.4628 16.5271L18.2837 14.7028ZM17.5962 3.41321C19.5944 4.23703 21 6.20361 21 8.5C21 11.3702 18.8042 13.7252 16 13.9776V11.9646C17.6967 11.7222 19 10.264 19 8.5C19 7.11935 18.2016 5.92603 17.041 5.35635L17.5962 3.41321Z"></path>
</svg>
),
},
]; ];
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -122,6 +137,9 @@ export default function BotDetailDialog({
<main className="flex flex-1 flex-col h-[70vh]"> <main className="flex flex-1 flex-col h-[70vh]">
<DialogHeader className="px-6 pt-6 pb-4 shrink-0"> <DialogHeader className="px-6 pt-6 pb-4 shrink-0">
<DialogTitle>{t('bots.createBot')}</DialogTitle> <DialogTitle>{t('bots.createBot')}</DialogTitle>
<DialogDescription className="sr-only">
{t('bots.createBot')}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6"> <div className="flex-1 overflow-y-auto px-6 pb-6">
<BotForm <BotForm
@@ -155,7 +173,7 @@ export default function BotDetailDialog({
return ( return (
<> <>
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="overflow-hidden p-0 !max-w-[50rem] max-h-[75vh] flex"> <DialogContent className="overflow-hidden p-0 !max-w-[70rem] max-h-[75vh] flex">
<SidebarProvider className="items-start w-full flex"> <SidebarProvider className="items-start w-full flex">
<Sidebar <Sidebar
collapsible="none" collapsible="none"
@@ -189,10 +207,25 @@ export default function BotDetailDialog({
<DialogTitle> <DialogTitle>
{activeMenu === 'config' {activeMenu === 'config'
? t('bots.editBot') ? t('bots.editBot')
: t('bots.botLogTitle')} : activeMenu === 'logs'
? t('bots.botLogTitle')
: t('bots.sessionMonitor.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="sr-only">
{activeMenu === 'config'
? t('bots.editBot')
: activeMenu === 'logs'
? t('bots.botLogTitle')
: t('bots.sessionMonitor.title')}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex-1 overflow-y-auto px-6 pb-6"> <div
className={
activeMenu === 'sessions'
? 'flex-1 min-h-0'
: 'flex-1 overflow-y-auto px-6 pb-6'
}
>
{activeMenu === 'config' && ( {activeMenu === 'config' && (
<BotForm <BotForm
initBotId={botId} initBotId={botId}
@@ -204,6 +237,9 @@ export default function BotDetailDialog({
{activeMenu === 'logs' && botId && ( {activeMenu === 'logs' && botId && (
<BotLogListComponent botId={botId} /> <BotLogListComponent botId={botId} />
)} )}
{activeMenu === 'sessions' && botId && (
<BotSessionMonitor botId={botId} />
)}
</div> </div>
{activeMenu === 'config' && ( {activeMenu === 'config' && (
<DialogFooter className="px-6 py-4 border-t shrink-0"> <DialogFooter className="px-6 py-4 border-t shrink-0">
@@ -238,6 +274,9 @@ export default function BotDetailDialog({
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t('common.confirmDelete')}</DialogTitle> <DialogTitle>{t('common.confirmDelete')}</DialogTitle>
<DialogDescription className="sr-only">
{t('bots.deleteConfirmation')}
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-4">{t('bots.deleteConfirmation')}</div> <div className="py-4">{t('bots.deleteConfirmation')}</div>
<DialogFooter> <DialogFooter>

View File

@@ -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<SessionInfo[]>([]);
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(
null,
);
const [messages, setMessages] = useState<SessionMessage[]>([]);
const [loadingSessions, setLoadingSessions] = useState(false);
const [loadingMessages, setLoadingMessages] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(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 <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 align-middle mx-0.5 px-1.5 py-0.5 bg-blue-200/60 dark:bg-blue-800/60 text-blue-700 dark:text-blue-300 rounded-md text-xs font-medium"
>
@{displayName}
</span>
);
}
case 'AtAll':
return (
<span
key={index}
className="inline-flex align-middle mx-0.5 px-1.5 py-0.5 bg-blue-200/60 dark:bg-blue-800/60 text-blue-700 dark:text-blue-300 rounded-md text-xs font-medium"
>
@All
</span>
);
case 'Image': {
const img = component as Image;
const imageUrl = img.url || (img.base64 ? img.base64 : '');
if (!imageUrl) {
return (
<span
key={index}
className="inline-flex items-center gap-1 text-muted-foreground text-xs"
>
[Image]
</span>
);
}
return (
<div key={index} className="my-1.5">
<img
src={imageUrl}
alt="Image"
className="max-w-full max-h-52 rounded-lg"
/>
</div>
);
}
case 'Voice': {
const voice = component as Voice;
const voiceUrl = voice.url || (voice.base64 ? voice.base64 : '');
if (!voiceUrl) {
return (
<span
key={index}
className="inline-flex items-center gap-1 text-muted-foreground text-xs"
>
🎙 [Voice]
</span>
);
}
return (
<div key={index} className="my-1">
<audio controls src={voiceUrl} className="h-8 max-w-[220px]" />
</div>
);
}
case 'Quote': {
const quote = component as Quote;
return (
<div
key={index}
className="mb-2 pl-2.5 border-l-2 border-gray-300 dark:border-gray-600 opacity-80"
>
<div className="text-sm">
{quote.origin?.map((comp, idx) =>
renderMessageComponent(comp as MessageChainComponent, idx),
)}
</div>
</div>
);
}
case 'Source':
return null;
case 'File': {
const file = component as MessageChainComponent & { name?: string };
return (
<span key={index} className="text-muted-foreground text-xs">
📎 {file.name || 'File'}
</span>
);
}
default:
return (
<span key={index} className="text-muted-foreground text-xs">
[{component.type}]
</span>
);
}
};
const renderMessageContent = (msg: SessionMessage) => {
const chain = parseMessageChain(msg.message_content);
return (
<div className="whitespace-pre-wrap break-words">
{chain.map((component, index) =>
renderMessageComponent(component, index),
)}
</div>
);
};
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 (
<div className="flex h-full min-h-0">
{/* Left Panel: Session List */}
<div className="w-64 flex-shrink-0 border-r flex flex-col min-h-0">
{/* Refresh Button */}
<div className="px-2 py-2 border-b shrink-0">
<Button
variant="ghost"
className="w-full h-9 text-sm text-muted-foreground"
onClick={loadSessions}
disabled={loadingSessions}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={cn(
'w-3.5 h-3.5 mr-1.5',
loadingSessions && 'animate-spin',
)}
>
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
</svg>
{t('bots.sessionMonitor.refresh')}
</Button>
</div>
{/* Session List */}
<ScrollArea className="flex-1 min-h-0">
{loadingSessions && sessions.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t('bots.sessionMonitor.loading')}
</div>
) : sessions.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noSessions')}
</div>
) : (
<div className="p-1">
{sessions.map((session) => {
const isSelected = selectedSessionId === session.session_id;
return (
<button
key={session.session_id}
className={cn(
'w-full text-left px-3 py-2.5 rounded-md transition-colors',
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
)}
onClick={() => setSelectedSessionId(session.session_id)}
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-sm font-medium truncate mr-2">
{session.user_id || session.session_id.slice(0, 12)}
</span>
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
{formatRelativeTime(session.last_activity)}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{session.platform && (
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
{session.platform}
</span>
)}
{session.is_active && (
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
</span>
)}
<span>{session.pipeline_name}</span>
</div>
</button>
);
})}
</div>
)}
</ScrollArea>
</div>
{/* Right Panel: Messages */}
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{!selectedSessionId ? (
<div className="text-center text-muted-foreground py-12 text-lg flex-1 flex items-center justify-center">
{t('bots.sessionMonitor.selectSession')}
</div>
) : (
<>
{/* Chat Header */}
<div className="px-6 py-3 border-b shrink-0 flex items-center justify-between">
<div className="min-w-0">
<div className="text-sm font-medium truncate">
{selectedSession?.user_id || selectedSessionId.slice(0, 20)}
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{selectedSession?.platform && (
<span>{selectedSession.platform}</span>
)}
{selectedSession?.pipeline_name && (
<>
{selectedSession?.platform && <span>·</span>}
<span>{selectedSession.pipeline_name}</span>
</>
)}
{selectedSession?.is_active && (
<>
<span>·</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
Active
</span>
</>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="w-8 h-8"
onClick={() => loadMessages(selectedSessionId)}
disabled={loadingMessages}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={cn('w-4 h-4', loadingMessages && 'animate-spin')}
>
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0 1 18.8-4.3M22 12.5a10 10 0 0 1-18.8 4.2" />
</svg>
</Button>
</div>
{/* Messages Area — matches DebugDialog style */}
<ScrollArea
ref={messagesContainerRef}
className="flex-1 p-6 overflow-y-auto min-h-0 bg-white dark:bg-black"
>
<div className="space-y-6">
{loadingMessages ? (
<div className="text-center text-muted-foreground py-12 text-lg">
{t('bots.sessionMonitor.loading')}
</div>
) : messages.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-lg">
{t('bots.sessionMonitor.noMessages')}
</div>
) : (
messages.map((msg) => {
const isUser = isUserMessage(msg);
return (
<div
key={msg.id}
className={cn(
'flex',
isUser ? 'justify-end' : 'justify-start',
)}
>
<div
className={cn(
'max-w-3xl px-5 py-3 rounded-2xl',
isUser
? 'bg-blue-100 dark:bg-blue-900 text-gray-900 dark:text-gray-100 rounded-br-none'
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded-bl-none',
msg.status === 'error' && 'ring-1 ring-red-400/50',
)}
>
{renderMessageContent(msg)}
{/* Role label + timestamp inside bubble, matching DebugDialog */}
<div
className={cn(
'text-xs mt-2 flex items-center gap-2',
isUser
? 'text-gray-600 dark:text-gray-300'
: 'text-gray-500 dark:text-gray-400',
)}
>
<span>
{isUser
? t('bots.sessionMonitor.userMessage', {
defaultValue: 'User',
})
: t('bots.sessionMonitor.botMessage', {
defaultValue: 'Assistant',
})}
</span>
<span className="tabular-nums">
{formatTime(msg.timestamp)}
</span>
{msg.status === 'error' && (
<span className="text-red-500">error</span>
)}
{msg.runner_name && (
<span className="opacity-70">
{msg.runner_name}
</span>
)}
</div>
</div>
</div>
);
})
)}
</div>
</ScrollArea>
</>
)}
</div>
</div>
);
}

View File

@@ -141,6 +141,11 @@ export default function DynamicFormComponent({
} }
}, [initialValues, form, itemConfigList]); }, [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(() => { useEffect(() => {
// Emit initial form values immediately so the parent always has a valid snapshot, // Emit initial form values immediately so the parent always has a valid snapshot,
@@ -154,7 +159,7 @@ export default function DynamicFormComponent({
}, },
{} as Record<string, object>, {} as Record<string, object>,
); );
onSubmit?.(initialFinalValues); onSubmitRef.current?.(initialFinalValues);
const subscription = form.watch(() => { const subscription = form.watch(() => {
const formValues = form.getValues(); const formValues = form.getValues();
@@ -165,10 +170,10 @@ export default function DynamicFormComponent({
}, },
{} as Record<string, object>, {} as Record<string, object>,
); );
onSubmit?.(finalValues); onSubmitRef.current?.(finalValues);
}); });
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [form, onSubmit, itemConfigList]); }, [form, itemConfigList]);
return ( return (
<Form {...form}> <Form {...form}>

View File

@@ -339,6 +339,64 @@ export class BackendClient extends BaseHttpClient {
return this.post(`/api/v1/platform/bots/${botId}/logs`, request); 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 ============ // ============ File management API ============
public uploadDocumentFile(file: File): Promise<{ file_id: string }> { public uploadDocumentFile(file: File): Promise<{ file_id: string }> {
const formData = new FormData(); const formData = new FormData();

View File

@@ -280,6 +280,25 @@ const enUS = {
viewDetails: 'Details', viewDetails: 'Details',
collapse: 'Collapse', collapse: 'Collapse',
imagesAttached: 'image(s) attached', 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: { plugins: {
title: 'Extensions', title: 'Extensions',

View File

@@ -281,6 +281,25 @@ const jaJP = {
allLevels: 'すべてのレベル', allLevels: 'すべてのレベル',
selectLevel: 'レベルを選択', selectLevel: 'レベルを選択',
levelsSelected: 'レベル選択済み', levelsSelected: 'レベル選択済み',
sessionMonitor: {
title: 'セッション監視',
sessions: 'セッション一覧',
noSessions: 'セッションが見つかりません',
selectSession: 'セッションを選択してメッセージを表示',
noMessages: 'このセッションにはメッセージがありません',
messages: '件のメッセージ',
messageCount: '{{count}} 件のメッセージ',
loading: '読み込み中...',
loadingSessions: 'セッションを読み込み中...',
loadingMessages: 'メッセージを読み込み中...',
user: 'ユーザー',
variables: '変数',
platform: 'プラットフォーム',
lastActive: '最終アクティブ',
refresh: '更新',
active: 'アクティブ',
inactive: '非アクティブ',
},
}, },
plugins: { plugins: {
title: '拡張機能', title: '拡張機能',

View File

@@ -269,6 +269,25 @@ const zhHans = {
viewDetails: '详情', viewDetails: '详情',
collapse: '收起', collapse: '收起',
imagesAttached: '张图片', imagesAttached: '张图片',
sessionMonitor: {
title: '会话监控',
sessions: '会话列表',
noSessions: '暂无会话',
selectSession: '选择一个会话查看消息',
noMessages: '该会话暂无消息',
messages: '条消息',
messageCount: '{{count}} 条消息',
loading: '加载中...',
loadingSessions: '加载会话中...',
loadingMessages: '加载消息中...',
user: '用户',
variables: '变量',
platform: '平台',
lastActive: '最近活跃',
refresh: '刷新',
active: '活跃',
inactive: '不活跃',
},
}, },
plugins: { plugins: {
title: '插件扩展', title: '插件扩展',

View File

@@ -264,6 +264,25 @@ const zhHant = {
allLevels: '全部級別', allLevels: '全部級別',
selectLevel: '選擇級別', selectLevel: '選擇級別',
levelsSelected: '個級別已選', levelsSelected: '個級別已選',
sessionMonitor: {
title: '會話監控',
sessions: '會話列表',
noSessions: '暫無會話',
selectSession: '選擇一個會話查看訊息',
noMessages: '該會話暫無訊息',
messages: '條訊息',
messageCount: '{{count}} 條訊息',
loading: '載入中...',
loadingSessions: '載入會話中...',
loadingMessages: '載入訊息中...',
user: '用戶',
variables: '變數',
platform: '平台',
lastActive: '最近活躍',
refresh: '重新整理',
active: '活躍',
inactive: '不活躍',
},
}, },
plugins: { plugins: {
title: '外掛擴展', title: '外掛擴展',