- {kbCardVO.description}
+
+
{kbCardVO.emoji || '📚'}
+
+
+ {kbCardVO.name}
+
+
+ {kbCardVO.description}
+
diff --git a/web/src/app/home/knowledge/components/kb-card/KBCardVO.ts b/web/src/app/home/knowledge/components/kb-card/KBCardVO.ts
index b13d2c24..e7c20ed9 100644
--- a/web/src/app/home/knowledge/components/kb-card/KBCardVO.ts
+++ b/web/src/app/home/knowledge/components/kb-card/KBCardVO.ts
@@ -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;
}
}
diff --git a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx
index 1d8088b9..8ed045c2 100644
--- a/web/src/app/home/knowledge/components/kb-form/KBForm.tsx
+++ b/web/src/app/home/knowledge/components/kb-form/KBForm.tsx
@@ -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"
>
-
(
-
-
- {t('knowledge.kbName')}
- *
-
-
-
-
-
-
- )}
- />
+ {/* Name and Emoji in same row */}
+
+ (
+
+
+ {t('knowledge.kbName')}
+ *
+
+
+
+
+
+
+ )}
+ />
+ (
+
+ {t('common.icon')}
+
+
+
+
+
+ )}
+ />
+
{
+ if (!userInfo) {
+ initializeUserInfo();
+ }
+ }, []);
+
const onSelectedChangeAction = useCallback((child: SidebarChildVO) => {
setTitle(child.name);
setSubtitle(child.description);
@@ -31,7 +45,9 @@ export default function HomeLayout({
return (
diff --git a/web/src/app/home/loading.tsx b/web/src/app/home/loading.tsx
new file mode 100644
index 00000000..89d0f1be
--- /dev/null
+++ b/web/src/app/home/loading.tsx
@@ -0,0 +1,9 @@
+import { LoadingSpinner } from '@/components/ui/loading-spinner';
+
+export default function Loading() {
+ return (
+
+
+
+ );
+}
diff --git a/web/src/app/home/monitoring/components/MessageContentRenderer.tsx b/web/src/app/home/monitoring/components/MessageContentRenderer.tsx
new file mode 100644
index 00000000..769aea92
--- /dev/null
+++ b/web/src/app/home/monitoring/components/MessageContentRenderer.tsx
@@ -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
('');
+ 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 {(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 ImageComponent;
+ const imageUrl = img.url || (img.base64 ? img.base64 : '');
+
+ if (!imageUrl) {
+ return (
+
+ [Image]
+
+ );
+ }
+
+ return (
+
+
{
+ e.stopPropagation();
+ setPreviewImageUrl(imageUrl);
+ setShowImagePreview(true);
+ }}
+ />
+
+ );
+ }
+
+ case 'File': {
+ const file = component as MessageChainComponent & { name?: string };
+ return (
+
+
+ {file.name || 'File'}
+
+ );
+ }
+
+ case 'Voice': {
+ const voice = component as Voice;
+ return (
+
+
+ Voice{voice.length ? ` ${voice.length}s` : ''}
+
+ );
+ }
+
+ case 'Quote': {
+ const quote = component as Quote;
+ return (
+
+ {quote.origin
+ ?.filter((c) => (c as MessageChainComponent).type === 'Plain')
+ .map((c) => (c as MessageChainComponent as Plain).text)
+ .join('') || '[Quote]'}
+
+ );
+ }
+
+ case 'Source':
+ return null;
+
+ default:
+ return (
+
+ [{component.type}]
+
+ );
+ }
+ };
+
+ 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 (
+
+ [Empty message]
+
+ );
+ }
+
+ // Render as message chain
+ return (
+ <>
+
+ {messageChain.map((component, index) =>
+ renderMessageComponent(component, index),
+ )}
+
+ setShowImagePreview(false)}
+ />
+ >
+ );
+ }
+
+ // Handle empty plain text
+ if (
+ !content ||
+ content.trim() === '' ||
+ content === '[]' ||
+ content === '""'
+ ) {
+ return (
+
+ [Empty message]
+
+ );
+ }
+
+ // Render as plain text
+ return {content};
+}
diff --git a/web/src/app/home/monitoring/components/MessageDetailsCard.tsx b/web/src/app/home/monitoring/components/MessageDetailsCard.tsx
new file mode 100644
index 00000000..723ec289
--- /dev/null
+++ b/web/src/app/home/monitoring/components/MessageDetailsCard.tsx
@@ -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 (
+
+ {/* Context Info Section */}
+ {details.message && (
+
+
+
+ {t('monitoring.messageList.viewDetails')}
+
+
+ {/* Metadata Grid */}
+
+ {details.message.platform && (
+
+
+ {t('monitoring.messageList.platform')}
+
+
+ {details.message.platform}
+
+
+ )}
+ {details.message.userId && (
+
+
+ {t('monitoring.messageList.user')}
+
+
+ {details.message.userId}
+
+
+ )}
+ {details.message.runnerName && (
+
+
+ {t('monitoring.messageList.runner')}
+
+
+ {details.message.runnerName}
+
+
+ )}
+
+
+ {t('monitoring.messageList.level')}
+
+
+ {details.message.level.toUpperCase()}
+
+
+
+
+ )}
+
+ {/* LLM Calls Section */}
+ {details.llmCalls && details.llmCalls.length > 0 && (
+
+
+
+ {t('monitoring.llmCalls.title')} ({details.llmCalls.length})
+
+
+ {/* LLM Stats Summary */}
+
+
+
+ {t('monitoring.llmCalls.totalTokens')}
+
+
+ {details.llmStats.totalTokens.toLocaleString()}
+
+
+
+
+ {t('monitoring.llmCalls.avgDuration')}
+
+
+ {details.llmStats.averageDurationMs}ms
+
+
+
+
+ {t('monitoring.llmCalls.calls')}
+
+
+ {details.llmStats.totalCalls}
+
+
+
+
+ {/* Individual LLM Calls */}
+
+ {details.llmCalls.map((call, index) => (
+
+
+
+
+ #{index + 1} {call.modelName}
+
+
+ {call.status}
+
+
+
+ {call.duration}ms
+
+
+
+
+
+ In:
+ {' '}
+ {call.tokens.input.toLocaleString()}
+
+
+
+ Out:
+ {' '}
+ {call.tokens.output.toLocaleString()}
+
+
+
+ Total:
+ {' '}
+ {call.tokens.total.toLocaleString()}
+
+
+ {call.errorMessage && (
+
+ {call.errorMessage}
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Errors Section */}
+ {details.errors && details.errors.length > 0 && (
+
+
+
+ {t('monitoring.errors.title')} ({details.errors.length})
+
+
+ {details.errors.map((error) => (
+
+
+ {error.errorType}
+
+
+ {error.errorMessage}
+
+ {error.stackTrace && (
+
+
+ {t('monitoring.errors.stackTrace')}
+
+
+ {error.stackTrace}
+
+
+ )}
+
+ ))}
+
+
+ )}
+
+ {/* Query Variables Section - Only show for non-local-agent runners */}
+ {queryVariables &&
+ Object.keys(queryVariables).length > 0 &&
+ details.message?.runnerName !== 'local-agent' && (
+
+
+
+ {t('monitoring.queryVariables.title')}
+
+
+ {Object.entries(queryVariables).map(([key, value]) => (
+
+
{key}
+
+ {value === null || value === undefined ? (
+ null
+ ) : typeof value === 'string' ? (
+ value || (
+ empty
+ )
+ ) : (
+ JSON.stringify(value)
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {/* 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) && (
+
+ {t('monitoring.messageDetails.noData')}
+
+ )}
+
+ );
+}
diff --git a/web/src/app/home/monitoring/components/filters/MonitoringFilters.tsx b/web/src/app/home/monitoring/components/filters/MonitoringFilters.tsx
new file mode 100644
index 00000000..4b9b1324
--- /dev/null
+++ b/web/src/app/home/monitoring/components/filters/MonitoringFilters.tsx
@@ -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([]);
+ const [pipelines, setPipelines] = useState([]);
+ 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 (
+
+ {/* Bot Filter */}
+
+
+
+
+
+ {/* Pipeline Filter */}
+
+
+
+
+
+ {/* Time Range Filter */}
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/home/monitoring/components/overview-cards/MetricCard.tsx b/web/src/app/home/monitoring/components/overview-cards/MetricCard.tsx
new file mode 100644
index 00000000..821a4558
--- /dev/null
+++ b/web/src/app/home/monitoring/components/overview-cards/MetricCard.tsx
@@ -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 (
+
+
+
+ {title}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {title}
+
+
+
+
+
+ {value}
+
+ {trend && (
+
+
+
+ {Math.abs(trend.value)}%
+
+
+ vs previous period
+
+
+ )}
+
+
+ );
+}
diff --git a/web/src/app/home/monitoring/components/overview-cards/OverviewCards.tsx b/web/src/app/home/monitoring/components/overview-cards/OverviewCards.tsx
new file mode 100644
index 00000000..5a097a16
--- /dev/null
+++ b/web/src/app/home/monitoring/components/overview-cards/OverviewCards.tsx
@@ -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: (
+
+ ),
+ 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: (
+
+ ),
+ 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: (
+
+ ),
+ 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: (
+
+ ),
+ trend: metrics?.trends
+ ? {
+ value: metrics.trends.sessions,
+ direction: (metrics.trends.sessions >= 0 ? 'up' : 'down') as
+ | 'up'
+ | 'down',
+ }
+ : undefined,
+ },
+ ];
+
+ return (
+
+ {/* Metric Cards */}
+
+ {cards.map((card, index) => (
+
+ ))}
+
+
+ {/* Traffic Chart */}
+
+
+ );
+}
diff --git a/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx b/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx
new file mode 100644
index 00000000..f4ac6bbd
--- /dev/null
+++ b/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx
@@ -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 = 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 (
+
+ );
+ }
+
+ if (chartData.length === 0) {
+ return (
+
+
+ {t('monitoring.trafficChart.title')}
+
+
+
+
+ {t('monitoring.trafficChart.noData')}
+
+
+
+ );
+ }
+
+ return (
+
+
+ {t('monitoring.trafficChart.title')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/web/src/app/home/monitoring/hooks/useMonitoringData.ts b/web/src/app/home/monitoring/hooks/useMonitoringData.ts
new file mode 100644
index 00000000..f6dfc985
--- /dev/null
+++ b/web/src/app/home/monitoring/hooks/useMonitoringData.ts
@@ -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(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState(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,
+ };
+}
diff --git a/web/src/app/home/monitoring/hooks/useMonitoringFilters.ts b/web/src/app/home/monitoring/hooks/useMonitoringFilters.ts
new file mode 100644
index 00000000..aaa914f0
--- /dev/null
+++ b/web/src/app/home/monitoring/hooks/useMonitoringFilters.ts
@@ -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(() => {
+ const botId = searchParams.get('botId');
+ return botId ? [botId] : [];
+ });
+
+ const [selectedPipelines, setSelectedPipelines] = useState(() => {
+ const pipelineId = searchParams.get('pipelineId');
+ return pipelineId ? [pipelineId] : [];
+ });
+
+ const [timeRange, setTimeRange] = useState('last24Hours');
+ const [customDateRange, setCustomDateRange] = useState(
+ 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,
+ };
+}
diff --git a/web/src/app/home/monitoring/page.tsx b/web/src/app/home/monitoring/page.tsx
new file mode 100644
index 00000000..d7ebc486
--- /dev/null
+++ b/web/src/app/home/monitoring/page.tsx
@@ -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;
+}
+
+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(
+ null,
+ );
+ const [messageDetails, setMessageDetails] = useState<
+ Record
+ >({});
+ const [loadingDetails, setLoadingDetails] = useState>(
+ {},
+ );
+
+ // State for expanded errors
+ const [expandedErrorId, setExpandedErrorId] = useState(null);
+
+ // State for controlled tabs
+ const [activeTab, setActiveTab] = useState('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 (
+
+ {/* Filters and Refresh Button - Sticky */}
+
+
+
+
+
+
+
+
+
+ {/* Content Area */}
+
+ {/* Overview Section */}
+
+
+ {/* Tabs Section */}
+
+
+
+
+
+ {t('monitoring.tabs.messages')}
+
+
+ {t('monitoring.tabs.modelCalls')}
+
+
+ {t('monitoring.tabs.errors')}
+
+
+
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading &&
+ data &&
+ data.messages &&
+ data.messages.length > 0 && (
+
+ {data.messages
+ .filter((msg) => {
+ // Filter out messages with empty content
+ const content = msg.messageContent?.trim();
+ return (
+ content && content !== '[]' && content !== '""'
+ );
+ })
+ .map((msg) => (
+
+ {/* Message Header - Always Visible */}
+
toggleMessageExpand(msg.id)}
+ >
+
+
+ {/* Expand Icon */}
+
+ {expandedMessageId === msg.id ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Message Info */}
+
+
+
+ ID: {msg.id}
+
+
+
+
+ {msg.botName}
+
+ →
+
+ {msg.pipelineName}
+
+ {msg.runnerName && (
+ <>
+
+ →
+
+
+ {msg.runnerName}
+
+ >
+ )}
+
+
+
+
+
+
+
+ {/* Status and Timestamp */}
+
+
+ {msg.timestamp.toLocaleString()}
+
+
+ {msg.level}
+
+
+
+
+
+ {/* Expanded Details */}
+ {expandedMessageId === msg.id && (
+
+ {loadingDetails[msg.id] && (
+
+
+
+ )}
+ {!loadingDetails[msg.id] &&
+ messageDetails[msg.id] && (
+
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+ {!loading &&
+ (!data || !data.messages || data.messages.length === 0) && (
+
+
+
+ {t('monitoring.messageList.noMessages')}
+
+
+ {t('monitoring.messageList.noMessagesDescription')}
+
+
+ )}
+
+
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading &&
+ data &&
+ data.modelCalls &&
+ data.modelCalls.length > 0 && (
+
+ {data.modelCalls.map((call) => (
+
+
+
+ {/* Query ID - only show if messageId exists */}
+ {call.messageId && (
+
+
+ Query ID: {call.messageId}
+
+
+
+ )}
+
+ {/* Model Type Badge */}
+
+ {call.modelType === 'llm'
+ ? t('monitoring.modelCalls.llmModel')
+ : t('monitoring.modelCalls.embeddingModel')}
+
+ {/* Call Type Badge for Embedding */}
+ {call.modelType === 'embedding' &&
+ call.callType && (
+
+ {call.callType === 'retrieve'
+ ? t(
+ 'monitoring.modelCalls.retrieveCall',
+ )
+ : t(
+ 'monitoring.modelCalls.embeddingCall',
+ )}
+
+ )}
+ {/* Status Badge */}
+
+ {call.status}
+
+
+ {/* Model Name */}
+
+ {call.modelName}
+
+ {/* Context Info - only for LLM calls */}
+ {call.modelType === 'llm' &&
+ call.botName &&
+ call.pipelineName && (
+
+ {call.botName} → {call.pipelineName}
+
+ )}
+ {/* Token Info */}
+
+
+ {call.modelType === 'llm' && call.tokens && (
+ <>
+
+ {t('monitoring.llmCalls.inputTokens')}:{' '}
+ {call.tokens.input}
+
+
+ {t('monitoring.llmCalls.outputTokens')}:{' '}
+ {call.tokens.output}
+
+
+ {t('monitoring.llmCalls.totalTokens')}:{' '}
+ {call.tokens.total}
+
+ >
+ )}
+ {call.modelType === 'embedding' && (
+ <>
+
+ {t(
+ 'monitoring.embeddingCalls.promptTokens',
+ )}
+ : {call.promptTokens}
+
+
+ {t(
+ 'monitoring.embeddingCalls.totalTokens',
+ )}
+ : {call.totalTokens}
+
+
+ {t(
+ 'monitoring.embeddingCalls.inputCount',
+ )}
+ : {call.inputCount}
+
+ >
+ )}
+
+ {t('monitoring.llmCalls.duration')}:{' '}
+ {call.duration}ms
+
+ {call.cost && (
+
+ {t('monitoring.llmCalls.cost')}: $
+ {call.cost.toFixed(4)}
+
+ )}
+
+ {/* Knowledge Base Info for Embedding */}
+ {call.modelType === 'embedding' &&
+ call.knowledgeBaseId && (
+
+ {t(
+ 'monitoring.embeddingCalls.knowledgeBase',
+ )}
+ : {call.knowledgeBaseId}
+
+ )}
+ {/* Query Text for Embedding Retrieve */}
+ {call.modelType === 'embedding' &&
+ call.queryText && (
+
+
+ {t(
+ 'monitoring.embeddingCalls.queryText',
+ )}
+ :{' '}
+
+
+ {call.queryText.length > 100
+ ? call.queryText.substring(0, 100) +
+ '...'
+ : call.queryText}
+
+
+ )}
+
+ {call.errorMessage && (
+
+ Error: {call.errorMessage}
+
+ )}
+
+
+ {call.timestamp.toLocaleString()}
+
+
+
+ ))}
+
+ )}
+
+ {!loading &&
+ (!data ||
+ !data.modelCalls ||
+ data.modelCalls.length === 0) && (
+
+
+
+ {t('monitoring.modelCalls.noData')}
+
+
+ )}
+
+
+
+
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && data && data.errors && data.errors.length > 0 && (
+
+ {data.errors.map((error) => (
+
+ {/* Error Header - Always Visible */}
+
toggleErrorExpand(error.id)}
+ >
+
+
+ {/* Expand Icon */}
+
+ {expandedErrorId === error.id ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Error Info */}
+
+ {/* Query ID */}
+
+
+ Query ID: {error.messageId || '-'}
+
+ {error.messageId && (
+
+ )}
+
+
+
+ {error.errorType}
+
+ →
+
+ {error.botName}
+
+ →
+
+ {error.pipelineName}
+
+
+
+ {error.errorMessage}
+
+
+
+
+ {/* Timestamp */}
+
+
+ {error.timestamp.toLocaleString()}
+
+
+
+
+
+ {/* Expanded Details */}
+ {expandedErrorId === error.id && (
+
+
+ {/* Error Details */}
+
+
+ {t('monitoring.errors.errorMessage')}
+
+
+ {error.errorMessage}
+
+
+
+ {/* Context Info */}
+
+
+ {t('monitoring.messageList.viewDetails')}
+
+
+
+
+ {t('monitoring.messageList.bot')}
+
+
+ {error.botName}
+
+
+
+
+ {t('monitoring.messageList.pipeline')}
+
+
+ {error.pipelineName}
+
+
+ {error.sessionId && (
+
+
+ {t('monitoring.sessions.sessionId')}
+
+
+ {error.sessionId}
+
+
+ )}
+
+
+
+ {/* Stack Trace */}
+ {error.stackTrace && (
+
+
+ {t('monitoring.errors.stackTrace')}
+
+
+ {error.stackTrace}
+
+
+ )}
+
+
+ )}
+
+ ))}
+
+ )}
+
+ {!loading &&
+ (!data || !data.errors || data.errors.length === 0) && (
+
+
+
+ {t('monitoring.errors.noErrors')}
+
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+export default function MonitoringPage() {
+ return (
+ }>
+
+
+ );
+}
diff --git a/web/src/app/home/monitoring/types/monitoring.ts b/web/src/app/home/monitoring/types/monitoring.ts
new file mode 100644
index 00000000..e1eb18d8
--- /dev/null
+++ b/web/src/app/home/monitoring/types/monitoring.ts
@@ -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;
+ };
+}
diff --git a/web/src/app/home/monitoring/utils/dateUtils.ts b/web/src/app/home/monitoring/utils/dateUtils.ts
new file mode 100644
index 00000000..42ef8039
--- /dev/null
+++ b/web/src/app/home/monitoring/utils/dateUtils.ts
@@ -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);
+}
diff --git a/web/src/app/home/pipelines/PipelineDetailDialog.tsx b/web/src/app/home/pipelines/PipelineDetailDialog.tsx
index b58afaa9..a6d40373 100644
--- a/web/src/app/home/pipelines/PipelineDetailDialog.tsx
+++ b/web/src/app/home/pipelines/PipelineDetailDialog.tsx
@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
+import { useRouter } from 'next/navigation';
import {
Dialog,
DialogContent,
@@ -19,6 +20,7 @@ import {
import PipelineFormComponent from './components/pipeline-form/PipelineFormComponent';
import DebugDialog from './components/debug-dialog/DebugDialog';
import PipelineExtension from './components/pipeline-extensions/PipelineExtension';
+import PipelineMonitoringTab from './components/monitoring-tab/PipelineMonitoringTab';
interface PipelineDialogProps {
open: boolean;
@@ -32,7 +34,7 @@ interface PipelineDialogProps {
onCancel: () => void;
}
-type DialogMode = 'config' | 'debug' | 'extensions';
+type DialogMode = 'config' | 'debug' | 'extensions' | 'monitoring';
export default function PipelineDialog({
open,
@@ -45,6 +47,7 @@ export default function PipelineDialog({
onCancel,
}: PipelineDialogProps) {
const { t } = useTranslation();
+ const router = useRouter();
const [pipelineId, setPipelineId] = useState(
propPipelineId,
);
@@ -108,6 +111,19 @@ export default function PipelineDialog({
),
},
+ {
+ key: 'monitoring',
+ label: t('pipelines.monitoring.title'),
+ icon: (
+
+ ),
+ },
];
const getDialogTitle = () => {
@@ -119,6 +135,9 @@ export default function PipelineDialog({
if (currentMode === 'extensions') {
return t('pipelines.extensions.title');
}
+ if (currentMode === 'monitoring') {
+ return t('pipelines.monitoring.title');
+ }
return t('pipelines.debugDialog.title');
};
@@ -240,6 +259,16 @@ export default function PipelineDialog({
onConnectionStatusChange={setIsWebSocketConnected}
/>
)}
+
+ {currentMode === 'monitoring' && pipelineId && (
+ {
+ router.push(`/home/monitoring?pipelineId=${pipelineId}`);
+ onOpenChange(false);
+ }}
+ />
+ )}
diff --git a/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx b/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx
new file mode 100644
index 00000000..8f5b1f2b
--- /dev/null
+++ b/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx
@@ -0,0 +1,665 @@
+'use client';
+
+import React, { useState, useMemo } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Button } from '@/components/ui/button';
+import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
+import { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData';
+import { MessageContentRenderer } from '@/app/home/monitoring/components/MessageContentRenderer';
+import { LoadingSpinner } from '@/components/ui/loading-spinner';
+import { httpClient } from '@/app/infra/http/HttpClient';
+import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
+
+interface PipelineMonitoringTabProps {
+ pipelineId: string;
+ onNavigateToMonitoring?: () => void;
+}
+
+interface RawMessageData {
+ id: string;
+ timestamp: string;
+ bot_id: string;
+ bot_name: string;
+ pipeline_id: string;
+ pipeline_name: string;
+ message_content: string;
+ session_id: string;
+ status: string;
+ level: string;
+ platform: string;
+ user_id: string;
+ runner_name: string;
+ variables: Record
;
+}
+
+interface RawLLMCallData {
+ id: string;
+ timestamp: string;
+ model_name: string;
+ status: string;
+ duration: number;
+ error_message: string | null;
+ input_tokens: number;
+ output_tokens: number;
+ total_tokens: number;
+}
+
+interface RawLLMStatsData {
+ total_calls: number;
+ total_input_tokens: number;
+ total_output_tokens: number;
+ total_tokens: number;
+ total_duration_ms: number;
+ average_duration_ms: number;
+}
+
+interface RawErrorData {
+ id: string;
+ timestamp: string;
+ error_type: string;
+ error_message: string;
+ stack_trace: string | null;
+}
+
+export default function PipelineMonitoringTab({
+ pipelineId,
+ onNavigateToMonitoring,
+}: PipelineMonitoringTabProps) {
+ const { t } = useTranslation();
+
+ // Filter state - only show data for this pipeline, last 24 hours
+ const filterState = useMemo(
+ () => ({
+ selectedBots: [],
+ selectedPipelines: [pipelineId],
+ timeRange: 'last24Hours' as const,
+ customDateRange: null,
+ }),
+ [pipelineId],
+ );
+
+ const { data, loading, refetch } = useMonitoringData(filterState);
+
+ const [expandedMessageId, setExpandedMessageId] = useState(
+ null,
+ );
+ const [messageDetails, setMessageDetails] = useState<
+ Record
+ >({});
+ const [loadingDetails, setLoadingDetails] = useState>(
+ {},
+ );
+ const [expandedErrorId, setExpandedErrorId] = useState(null);
+ const [activeTab, setActiveTab] = useState('messages');
+
+ const toggleMessageExpand = async (messageId: string) => {
+ if (expandedMessageId === messageId) {
+ setExpandedMessageId(null);
+ } else {
+ setExpandedMessageId(messageId);
+
+ if (!messageDetails[messageId]) {
+ setLoadingDetails((prev) => ({ ...prev, [messageId]: true }));
+ try {
+ const result = await httpClient.get<{
+ message_id: string;
+ found: boolean;
+ message: RawMessageData | null;
+ llm_calls: RawLLMCallData[];
+ llm_stats: RawLLMStatsData;
+ errors: RawErrorData[];
+ }>(`/api/v1/monitoring/messages/${messageId}/details`);
+
+ if (result) {
+ setMessageDetails((prev) => ({
+ ...prev,
+ [messageId]: {
+ messageId: result.message_id,
+ found: result.found,
+ message: result.message
+ ? {
+ id: result.message.id,
+ timestamp: new Date(result.message.timestamp),
+ botId: result.message.bot_id,
+ botName: result.message.bot_name,
+ pipelineId: result.message.pipeline_id,
+ pipelineName: result.message.pipeline_name,
+ messageContent: result.message.message_content,
+ sessionId: result.message.session_id,
+ status: result.message.status,
+ level: result.message.level,
+ platform: result.message.platform,
+ userId: result.message.user_id,
+ runnerName: result.message.runner_name,
+ variables: result.message.variables,
+ }
+ : undefined,
+ llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
+ id: call.id,
+ timestamp: new Date(call.timestamp),
+ modelName: call.model_name,
+ status: call.status,
+ duration: call.duration,
+ errorMessage: call.error_message,
+ tokens: {
+ input: call.input_tokens || 0,
+ output: call.output_tokens || 0,
+ total: call.total_tokens || 0,
+ },
+ })),
+ errors: result.errors.map((error: RawErrorData) => ({
+ id: error.id,
+ timestamp: new Date(error.timestamp),
+ errorType: error.error_type,
+ errorMessage: error.error_message,
+ stackTrace: error.stack_trace,
+ })),
+ llmStats: {
+ totalCalls: result.llm_stats.total_calls,
+ totalInputTokens: result.llm_stats.total_input_tokens,
+ totalOutputTokens: result.llm_stats.total_output_tokens,
+ totalTokens: result.llm_stats.total_tokens,
+ totalDurationMs: result.llm_stats.total_duration_ms,
+ averageDurationMs: result.llm_stats.average_duration_ms,
+ },
+ } as MessageDetails,
+ }));
+ }
+ } catch (error) {
+ console.error('Failed to fetch message details:', error);
+ } finally {
+ setLoadingDetails((prev) => ({ ...prev, [messageId]: false }));
+ }
+ }
+ }
+ };
+
+ const toggleErrorExpand = (errorId: string) => {
+ if (expandedErrorId === errorId) {
+ setExpandedErrorId(null);
+ } else {
+ setExpandedErrorId(errorId);
+ }
+ };
+
+ const jumpToMessage = async (messageId: string) => {
+ setActiveTab('messages');
+ // Small delay to ensure tab transition completes before expanding
+ setTimeout(() => {
+ toggleMessageExpand(messageId);
+ }, 100);
+ };
+
+ return (
+
+ {/* Header with refresh button */}
+
+
+ {t('pipelines.monitoring.description')}
+
+
+ {onNavigateToMonitoring && (
+
+ )}
+
+
+
+
+ {/* Overview Stats */}
+ {data && (
+
+
+
+ {t('monitoring.totalMessages')}
+
+
+ {data.overview.totalMessages}
+
+
+
+
+ {t('monitoring.successRate')}
+
+
+ {data.overview.successRate.toFixed(1)}%
+
+
+
+
+ {t('monitoring.tabs.errors')}
+
+
+ {data.errors.length}
+
+
+
+ )}
+
+ {/* Tabs */}
+
+
+
+ {t('monitoring.tabs.messages')}
+
+
+ {t('monitoring.tabs.errors')}
+
+
+ {t('monitoring.tabs.modelCalls')}
+
+
+
+
+ {/* Messages Tab */}
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && data && data.messages && data.messages.length > 0 && (
+
+ {data.messages
+ .filter((msg) => {
+ const content = msg.messageContent?.trim();
+ return content && content !== '[]' && content !== '""';
+ })
+ .map((msg) => (
+
+
toggleMessageExpand(msg.id)}
+ >
+
+
+
+ {expandedMessageId === msg.id ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {msg.status}
+
+
+ {msg.botName}
+
+
+
+
+
+
+
+
+ {msg.timestamp.toLocaleString()}
+
+
+
+
+ {expandedMessageId === msg.id && (
+
+ {loadingDetails[msg.id] && (
+
+
+
+ )}
+
+ {!loadingDetails[msg.id] &&
+ messageDetails[msg.id] && (
+
+ {messageDetails[msg.id].errors.length > 0 && (
+
+
+ {t('monitoring.errors.errorMessage')}
+
+ {messageDetails[msg.id].errors.map(
+ (error) => (
+
+
+ {error.errorType}:{' '}
+ {error.errorMessage}
+
+ {error.stackTrace && (
+
+ {error.stackTrace}
+
+ )}
+
+ ),
+ )}
+
+ )}
+
+ {messageDetails[msg.id].llmCalls.length > 0 && (
+
+
+ {t('monitoring.tabs.modelCalls')} (
+ {messageDetails[msg.id].llmCalls.length})
+
+
+
+ {t('monitoring.llmCalls.totalTokens')}:{' '}
+ {
+ messageDetails[msg.id].llmStats
+ .totalTokens
+ }
+
+
+ {t('monitoring.llmCalls.duration')}:{' '}
+ {messageDetails[
+ msg.id
+ ].llmStats.totalDurationMs.toFixed(0)}
+ ms
+
+
+
+ )}
+
+ )}
+
+ )}
+
+ ))}
+
+ )}
+
+ {!loading &&
+ (!data || !data.messages || data.messages.length === 0) && (
+
+
+
+ {t('monitoring.messageList.noMessages')}
+
+
+ )}
+
+
+ {/* Errors Tab */}
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && data && data.errors && data.errors.length > 0 && (
+
+ {data.errors.map((error) => (
+
+
toggleErrorExpand(error.id)}
+ >
+
+
+
+ {expandedErrorId === error.id ? (
+
+ ) : (
+
+ )}
+
+
+
+ {error.messageId && (
+
+ )}
+
+
+ {error.errorType}
+
+
+ {error.errorMessage}
+
+
+
+
+ {error.timestamp.toLocaleString()}
+
+
+
+
+ {expandedErrorId === error.id && (
+
+
+
+
+ {t('monitoring.errors.errorMessage')}
+
+
+ {error.errorMessage}
+
+
+
+ {error.stackTrace && (
+
+
+ {t('monitoring.errors.stackTrace')}
+
+
+ {error.stackTrace}
+
+
+ )}
+
+
+ )}
+
+ ))}
+
+ )}
+
+ {!loading &&
+ (!data || !data.errors || data.errors.length === 0) && (
+
+
+
+ {t('monitoring.errors.noErrors')}
+
+
+ )}
+
+
+ {/* LLM Calls Tab */}
+
+ {loading && (
+
+
+
+ )}
+
+ {!loading && data && data.llmCalls && data.llmCalls.length > 0 && (
+
+ {data.llmCalls.map((call) => (
+
+
+
+
+
+ {call.status}
+
+
+
+ {call.modelName}
+
+
+
+
+ {t('monitoring.llmCalls.inputTokens')}:{' '}
+ {call.tokens.input}
+
+
+ {t('monitoring.llmCalls.outputTokens')}:{' '}
+ {call.tokens.output}
+
+
+ {t('monitoring.llmCalls.totalTokens')}:{' '}
+ {call.tokens.total}
+
+
+ {t('monitoring.llmCalls.duration')}:{' '}
+ {call.duration}ms
+
+ {call.cost && (
+
+ {t('monitoring.llmCalls.cost')}: $
+ {call.cost.toFixed(4)}
+
+ )}
+
+
+ {call.errorMessage && (
+
+ Error: {call.errorMessage}
+
+ )}
+
+
+ {call.timestamp.toLocaleString()}
+
+
+
+ ))}
+
+ )}
+
+ {!loading &&
+ (!data || !data.llmCalls || data.llmCalls.length === 0) && (
+
+
+
+ {t('monitoring.llmCalls.noData')}
+
+
+ )}
+
+
+
+
+ );
+}
diff --git a/web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx b/web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx
index 52b3aef9..27e3b3b5 100644
--- a/web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx
+++ b/web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx
@@ -8,12 +8,15 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
return (
-
-
- {cardVO.name}
-
-
- {cardVO.description}
+
+
{cardVO.emoji || '⚙️'}
+
+
+ {cardVO.name}
+
+
+ {cardVO.description}
+
@@ -33,8 +36,8 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
-
- {cardVO.isDefault && (
+ {cardVO.isDefault && (
+
+
+ )}
);
}
diff --git a/web/src/app/home/pipelines/components/pipeline-card/PipelineCardVO.ts b/web/src/app/home/pipelines/components/pipeline-card/PipelineCardVO.ts
index 4ac80938..77349fd3 100644
--- a/web/src/app/home/pipelines/components/pipeline-card/PipelineCardVO.ts
+++ b/web/src/app/home/pipelines/components/pipeline-card/PipelineCardVO.ts
@@ -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;
}
}
diff --git a/web/src/app/home/pipelines/components/pipeline-card/pipelineCard.module.css b/web/src/app/home/pipelines/components/pipeline-card/pipelineCard.module.css
index d2bfebca..df5c9cf9 100644
--- a/web/src/app/home/pipelines/components/pipeline-card/pipelineCard.module.css
+++ b/web/src/app/home/pipelines/components/pipeline-card/pipelineCard.module.css
@@ -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 {
diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
index 65bbc526..b669e471 100644
--- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
+++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
@@ -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
({
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' && (
-
(
-
-
- {t('common.name')}
- *
-
-
-
-
-
-
- )}
- />
+ {/* Name and Emoji in same row */}
+
+ (
+
+
+ {t('common.name')}
+ *
+
+
+
+
+
+
+ )}
+ />
+ (
+
+ {t('common.icon')}
+
+
+
+
+
+ )}
+ />
+
void;
}) {
const { t } = useTranslation();
- const searchParams = useSearchParams();
const [searchQuery, setSearchQuery] = useState('');
const [componentFilter, setComponentFilter] = useState('all');
+ const [selectedTags, setSelectedTags] = useState([]);
+ const [availableTags, setAvailableTags] = useState([]);
+ const [tagNames, setTagNames] = useState>({});
const [plugins, setPlugins] = useState([]);
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 = {};
+ 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({
{/* Fixed header with search and sort controls */}
- {/* Search box */}
-
+ {/* Search box and Tags filter */}
+
{/* Component filter and sort */}
@@ -460,8 +496,7 @@ function MarketPageContent({
>
{isLoading ? (
-
- {t('market.loading')}
+
) : plugins.length === 0 ? (
@@ -477,6 +512,7 @@ function MarketPageContent({
key={plugin.pluginId}
cardVO={plugin}
onInstall={handleInstallPlugin}
+ tagNames={tagNames}
/>
))}
@@ -484,8 +520,7 @@ function MarketPageContent({
{/* Loading more indicator */}
{isLoadingMore && (
-
- {t('market.loadingMore')}
+
)}
@@ -522,8 +557,7 @@ export default function MarketPage({
fallback={
}
diff --git a/web/src/app/home/plugins/components/plugin-market/TagsFilter.tsx b/web/src/app/home/plugins/components/plugin-market/TagsFilter.tsx
new file mode 100644
index 00000000..3b1ca204
--- /dev/null
+++ b/web/src/app/home/plugins/components/plugin-market/TagsFilter.tsx
@@ -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 (
+
+ );
+}
diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx
index 7936e5d7..530a0b03 100644
--- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx
+++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx
@@ -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
;
}) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
@@ -42,13 +44,6 @@ export default function PluginMarketCardComponent({
KnowledgeRetriever: ,
};
- const componentKindNameMap: Record = {
- Tool: t('plugins.componentName.Tool'),
- EventListener: t('plugins.componentName.EventListener'),
- Command: t('plugins.componentName.Command'),
- KnowledgeRetriever: t('plugins.componentName.KnowledgeRetriever'),
- };
-
return (
- {/* 下部分:下载量和组件列表 */}
-
-
-
-
- {cardVO.installCount.toLocaleString()}
+ {/* 下部分:下载量、标签和组件列表 */}
+
+
+ {/* 下载数量 */}
+
+
+
+ {cardVO.installCount?.toLocaleString() ?? '0'}
+
+
+ {/* Tags */}
+ {cardVO.tags && cardVO.tags.length > 0 && (
+
+ {cardVO.tags.slice(0, 2).map((tag) => (
+
+
+ {tagNames[tag] || tag}
+
+ ))}
+ {cardVO.tags.length > 2 && (
+
+ +{cardVO.tags.length - 2}
+
+ )}
+
+ )}
{/* 组件列表 */}
@@ -127,10 +161,6 @@ export default function PluginMarketCardComponent({
className="flex items-center gap-1"
>
{kindIconMap[kind]}
- {/* 响应式显示组件名称:在中等屏幕以上显示 */}
-
- {componentKindNameMap[kind]}
-
{count}
))}
diff --git a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts
index 481d25b7..50f40c0f 100644
--- a/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts
+++ b/web/src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts
@@ -9,6 +9,7 @@ export interface IPluginMarketCardVO {
githubURL: string;
version: string;
components?: Record
;
+ tags?: string[];
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
@@ -22,6 +23,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
installCount: number;
version: string;
components?: Record;
+ 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;
}
}
diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts
index ce5043ea..50bc13e8 100644
--- a/web/src/app/infra/entities/api/index.ts
+++ b/web/src/app/infra/entities/api/index.ts
@@ -78,6 +78,7 @@ export interface KnowledgeBase {
created_at?: string;
updated_at?: string;
top_k: number;
+ emoji?: string;
}
export interface ApiRespProviderEmbeddingModels {
@@ -110,6 +111,7 @@ export interface Pipeline {
is_default?: boolean;
created_at?: string;
updated_at?: string;
+ emoji?: string;
}
export interface ApiRespPlatformAdapters {
@@ -168,6 +170,7 @@ export interface KnowledgeBase {
top_k: number;
created_at?: string;
updated_at?: string;
+ emoji?: string;
}
export interface ExternalKnowledgeBase {
@@ -179,6 +182,7 @@ export interface ExternalKnowledgeBase {
plugin_name: string;
retriever_name: string;
retriever_config?: Record;
+ emoji?: string;
}
export interface ApiRespExternalKnowledgeBases {
@@ -306,6 +310,7 @@ interface GetPipeline {
stages: string[];
updated_at: string;
uuid: string;
+ emoji?: string;
}
export interface GetPipelineResponseData {
diff --git a/web/src/app/infra/http/BackendClient.ts b/web/src/app/infra/http/BackendClient.ts
index dd4b1feb..bb913bd0 100644
--- a/web/src/app/infra/http/BackendClient.ts
+++ b/web/src/app/infra/http/BackendClient.ts
@@ -803,4 +803,150 @@ export class BackendClient extends BaseHttpClient {
}
return response.data.data;
}
+
+ // ============ Monitoring API ============
+ public getMonitoringData(params: {
+ botId?: string[];
+ pipelineId?: string[];
+ startTime?: string;
+ endTime?: string;
+ limit?: number;
+ }): Promise<{
+ overview: {
+ total_messages: number;
+ llm_calls: number;
+ embedding_calls: number;
+ model_calls: number;
+ success_rate: number;
+ active_sessions: number;
+ };
+ messages: Array<{
+ id: string;
+ timestamp: string;
+ bot_id: string;
+ bot_name: string;
+ pipeline_id: string;
+ pipeline_name: string;
+ message_content: string;
+ session_id: string;
+ status: string;
+ level: string;
+ platform?: string;
+ user_id?: string;
+ runner_name?: string;
+ variables?: string;
+ }>;
+ llmCalls: Array<{
+ id: string;
+ timestamp: string;
+ model_name: string;
+ input_tokens: number;
+ output_tokens: number;
+ total_tokens: number;
+ duration: number;
+ cost?: number;
+ status: string;
+ bot_id: string;
+ bot_name: string;
+ pipeline_id: string;
+ pipeline_name: string;
+ error_message?: string;
+ message_id?: string;
+ }>;
+ embeddingCalls: Array<{
+ id: string;
+ timestamp: string;
+ model_name: string;
+ prompt_tokens: number;
+ total_tokens: number;
+ duration: number;
+ input_count: number;
+ status: string;
+ error_message?: string;
+ knowledge_base_id?: string;
+ query_text?: string;
+ session_id?: string;
+ message_id?: string;
+ call_type?: string;
+ }>;
+ sessions: Array<{
+ session_id: string;
+ bot_id: string;
+ bot_name: string;
+ pipeline_id: string;
+ pipeline_name: string;
+ message_count: number;
+ last_activity: string;
+ start_time: string;
+ platform?: string;
+ user_id?: string;
+ }>;
+ errors: Array<{
+ id: string;
+ timestamp: string;
+ error_type: string;
+ error_message: string;
+ bot_id: string;
+ bot_name: string;
+ pipeline_id: string;
+ pipeline_name: string;
+ session_id?: string;
+ stack_trace?: string;
+ message_id?: string;
+ }>;
+ totalCount: {
+ messages: number;
+ llmCalls: number;
+ embeddingCalls: number;
+ sessions: number;
+ errors: number;
+ };
+ }> {
+ const queryParams = new URLSearchParams();
+ if (params.botId) {
+ params.botId.forEach((id) => queryParams.append('botId', id));
+ }
+ if (params.pipelineId) {
+ params.pipelineId.forEach((id) => queryParams.append('pipelineId', id));
+ }
+ if (params.startTime) {
+ queryParams.append('startTime', params.startTime);
+ }
+ if (params.endTime) {
+ queryParams.append('endTime', params.endTime);
+ }
+ if (params.limit) {
+ queryParams.append('limit', params.limit.toString());
+ }
+
+ return this.get(`/api/v1/monitoring/data?${queryParams.toString()}`);
+ }
+
+ public getMonitoringOverview(params: {
+ botId?: string[];
+ pipelineId?: string[];
+ startTime?: string;
+ endTime?: string;
+ }): Promise<{
+ total_messages: number;
+ llm_calls: number;
+ success_rate: number;
+ active_sessions: number;
+ }> {
+ const queryParams = new URLSearchParams();
+ if (params.botId) {
+ params.botId.forEach((id) => queryParams.append('botId', id));
+ }
+ if (params.pipelineId) {
+ params.pipelineId.forEach((id) => queryParams.append('pipelineId', id));
+ }
+ if (params.startTime) {
+ queryParams.append('startTime', params.startTime);
+ }
+ if (params.endTime) {
+ queryParams.append('endTime', params.endTime);
+ }
+
+ return this.get(`/api/v1/monitoring/overview?${queryParams.toString()}`);
+ }
}
diff --git a/web/src/app/infra/http/CloudServiceClient.ts b/web/src/app/infra/http/CloudServiceClient.ts
index 61d71e28..cefd97af 100644
--- a/web/src/app/infra/http/CloudServiceClient.ts
+++ b/web/src/app/infra/http/CloudServiceClient.ts
@@ -35,6 +35,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_by?: string,
sort_order?: string,
component_filter?: string,
+ tags_filter?: string[],
): Promise {
return this.post(
'/api/v1/marketplace/plugins/search',
@@ -45,6 +46,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_by,
sort_order,
component_filter,
+ tags_filter,
},
);
}
@@ -92,6 +94,20 @@ export class CloudServiceClient extends BaseHttpClient {
public getLangBotReleases(): Promise {
return this.get('/api/v1/dist/info/releases');
}
+
+ public getAllTags(): Promise<{ tags: PluginTag[] }> {
+ return this.get<{ tags: PluginTag[] }>('/api/v1/marketplace/tags');
+ }
+}
+
+export interface PluginTag {
+ tag: string;
+ display_name: {
+ zh_Hans?: string;
+ en_US?: string;
+ zh_Hant?: string;
+ ja_JP?: string;
+ };
}
export interface GitHubRelease {
diff --git a/web/src/app/infra/http/index.ts b/web/src/app/infra/http/index.ts
index 4ce98b1d..b4b720d2 100644
--- a/web/src/app/infra/http/index.ts
+++ b/web/src/app/infra/http/index.ts
@@ -12,6 +12,13 @@ export let systemInfo: ApiRespSystemInfo = {
disable_models_service: false,
};
+// 用户信息
+export let userInfo: {
+ user: string;
+ account_type: 'local' | 'space';
+ has_password: boolean;
+} | null = null;
+
/**
* 获取基础 URL
*/
@@ -24,6 +31,8 @@ const getBaseURL = (): string => {
// 创建后端客户端实例
export const backendClient = new BackendClient(getBaseURL());
+// 为了兼容性,也导出为 httpClient
+export const httpClient = backendClient;
// 创建云服务客户端实例(初始化时使用默认 URL)
export const cloudServiceClient = new CloudServiceClient(
@@ -82,6 +91,27 @@ export const initializeSystemInfo = async (): Promise => {
}
};
+/**
+ * 初始化用户信息
+ * 应该在用户登录后调用此方法
+ */
+export const initializeUserInfo = async (): Promise => {
+ try {
+ userInfo = await backendClient.getUserInfo();
+ } catch (error) {
+ console.error('Failed to initialize user info:', error);
+ userInfo = null;
+ }
+};
+
+/**
+ * 清除用户信息
+ * 应该在用户登出时调用此方法
+ */
+export const clearUserInfo = (): void => {
+ userInfo = null;
+};
+
// 导出类型,以便其他地方使用
export type { ResponseData, RequestConfig } from './BaseHttpClient';
export { BaseHttpClient } from './BaseHttpClient';
diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx
index 864500cc..592f2330 100644
--- a/web/src/app/layout.tsx
+++ b/web/src/app/layout.tsx
@@ -7,7 +7,8 @@ import { ThemeProvider } from '@/components/providers/theme-provider';
export const metadata: Metadata = {
title: 'LangBot',
- description: 'LangBot 是大模型原生即时通信机器人平台',
+ description:
+ 'Production-grade platform for building agentic IM bots, integrated with Telegram, Slack, Discord, WeChat, QQ, etc.',
};
export default function RootLayout({
diff --git a/web/src/app/login/page.tsx b/web/src/app/login/page.tsx
index 4ce2f722..05ab6e34 100644
--- a/web/src/app/login/page.tsx
+++ b/web/src/app/login/page.tsx
@@ -21,7 +21,7 @@ import {
FormMessage,
} from '@/components/ui/form';
import { useEffect, useState } from 'react';
-import { httpClient } from '@/app/infra/http/HttpClient';
+import { httpClient, initializeUserInfo } from '@/app/infra/http';
import { useRouter } from 'next/navigation';
import { Mail, Lock, Loader2 } from 'lucide-react';
import langbotIcon from '@/app/assets/langbot-logo.webp';
@@ -29,6 +29,7 @@ import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import Link from 'next/link';
import { ThemeToggle } from '@/components/ui/theme-toggle';
+import { LoadingSpinner } from '@/components/ui/loading-spinner';
const formSchema = (t: (key: string) => string) =>
z.object({
@@ -95,9 +96,10 @@ export default function Login() {
function handleLogin(username: string, password: string) {
httpClient
.authUser(username, password)
- .then((res) => {
+ .then(async (res) => {
localStorage.setItem('token', res.token);
localStorage.setItem('userEmail', username);
+ await initializeUserInfo();
router.push('/home');
toast.success(t('common.loginSuccess'));
})
@@ -122,7 +124,7 @@ export default function Login() {
if (loading) {
return (
-
+
);
}
diff --git a/web/src/components/ui/emoji-picker.tsx b/web/src/components/ui/emoji-picker.tsx
new file mode 100644
index 00000000..a87027b1
--- /dev/null
+++ b/web/src/components/ui/emoji-picker.tsx
@@ -0,0 +1,253 @@
+import React, { useState } from 'react';
+import { Button } from '@/components/ui/button';
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover';
+
+interface EmojiPickerProps {
+ value?: string;
+ onChange: (emoji: string) => void;
+ disabled?: boolean;
+}
+
+// 扩展的emoji分类
+const EMOJI_CATEGORIES = {
+ common: [
+ '⚙️',
+ '📚',
+ '🔗',
+ '📁',
+ '💡',
+ '🎯',
+ '✨',
+ '🚀',
+ '📝',
+ '🔧',
+ '⚡',
+ '🔥',
+ '💎',
+ '🎨',
+ '🎭',
+ ],
+ objects: [
+ '📦',
+ '📂',
+ '📋',
+ '📌',
+ '🔖',
+ '💼',
+ '🗂️',
+ '📮',
+ '🗃️',
+ '📊',
+ '📈',
+ '📉',
+ '🗄️',
+ '📇',
+ '🗳️',
+ ],
+ symbols: [
+ '🔴',
+ '🟠',
+ '🟡',
+ '🟢',
+ '🔵',
+ '🟣',
+ '⚪',
+ '⚫',
+ '🟤',
+ '🔺',
+ '🔻',
+ '🔶',
+ '🔷',
+ '🔸',
+ '🔹',
+ ],
+ nature: [
+ '🌟',
+ '⭐',
+ '🌈',
+ '💧',
+ '🌍',
+ '🌙',
+ '☀️',
+ '🌱',
+ '🌲',
+ '🌳',
+ '🌴',
+ '🌵',
+ '🌾',
+ '🍀',
+ '🌻',
+ ],
+ faces: [
+ '😀',
+ '😊',
+ '🤔',
+ '😎',
+ '🤖',
+ '👾',
+ '💬',
+ '💭',
+ '❤️',
+ '⚠️',
+ '✅',
+ '❌',
+ '🎉',
+ '🎊',
+ '🎈',
+ ],
+ tech: [
+ '💻',
+ '📱',
+ '⌨️',
+ '🖥️',
+ '🖱️',
+ '💾',
+ '💿',
+ '📀',
+ '🔌',
+ '🔋',
+ '📡',
+ '🛰️',
+ '🖨️',
+ '🖲️',
+ '💽',
+ ],
+ science: [
+ '🔬',
+ '🔭',
+ '⚗️',
+ '🧪',
+ '🧬',
+ '🧫',
+ '🩺',
+ '💊',
+ '💉',
+ '🌡️',
+ '🧲',
+ '⚛️',
+ '🧬',
+ '🦠',
+ '🧫',
+ ],
+ business: [
+ '💼',
+ '📊',
+ '📈',
+ '💰',
+ '💵',
+ '💴',
+ '💶',
+ '💷',
+ '💳',
+ '💸',
+ '📉',
+ '💹',
+ '🏦',
+ '🏢',
+ '🏭',
+ ],
+};
+
+const CATEGORY_LABELS: { [key: string]: string } = {
+ common: '常用',
+ objects: '物品',
+ symbols: '符号',
+ nature: '自然',
+ faces: '表情',
+ tech: '科技',
+ science: '科学',
+ business: '商业',
+};
+
+// 每个分类的代表性 emoji(用于分页按钮)
+const CATEGORY_ICONS: { [key: string]: string } = {
+ common: '⭐',
+ objects: '📦',
+ symbols: '🔴',
+ nature: '🌟',
+ faces: '😀',
+ tech: '💻',
+ science: '🔬',
+ business: '💼',
+};
+
+export default function EmojiPicker({
+ value,
+ onChange,
+ disabled,
+}: EmojiPickerProps) {
+ const [open, setOpen] = useState(false);
+ const [activeCategory, setActiveCategory] = useState('common');
+
+ const handleEmojiSelect = (emoji: string) => {
+ onChange(emoji);
+ setOpen(false);
+ };
+
+ const currentEmojis =
+ EMOJI_CATEGORIES[activeCategory as keyof typeof EMOJI_CATEGORIES];
+
+ return (
+
+
+
+
+
+
+ {/* 分类标题 */}
+
+ {CATEGORY_LABELS[activeCategory]}
+
+
+ {/* Emoji 网格 */}
+
+ {currentEmojis.map((emoji, index) => (
+
+ ))}
+
+
+ {/* 分类切换按钮 */}
+
+
+ {Object.keys(EMOJI_CATEGORIES).map((category) => (
+
+ ))}
+
+
+
+
+
+ );
+}
diff --git a/web/src/components/ui/loading-spinner.tsx b/web/src/components/ui/loading-spinner.tsx
new file mode 100644
index 00000000..42c34840
--- /dev/null
+++ b/web/src/components/ui/loading-spinner.tsx
@@ -0,0 +1,83 @@
+import { Loader2 } from 'lucide-react';
+import { cn } from '@/lib/utils';
+
+interface LoadingSpinnerProps {
+ /**
+ * Size variant of the spinner
+ * @default 'default'
+ */
+ size?: 'sm' | 'default' | 'lg';
+ /**
+ * Additional CSS classes
+ */
+ className?: string;
+ /**
+ * Loading text to display below the spinner
+ */
+ text?: string;
+ /**
+ * Whether to display as full page overlay
+ * @default false
+ */
+ fullPage?: boolean;
+}
+
+const sizeMap = {
+ sm: 'h-4 w-4',
+ default: 'h-8 w-8',
+ lg: 'h-12 w-12',
+};
+
+const textSizeMap = {
+ sm: 'text-xs',
+ default: 'text-sm',
+ lg: 'text-base',
+};
+
+export function LoadingSpinner({
+ size = 'default',
+ className,
+ text = '加载中...',
+ fullPage = false,
+}: LoadingSpinnerProps) {
+ const spinner = (
+
+
+ {text && (
+
{text}
+ )}
+
+ );
+
+ if (fullPage) {
+ return (
+
+ {spinner}
+
+ );
+ }
+
+ return spinner;
+}
+
+/**
+ * Full page loading component for use in page.tsx or layout.tsx
+ */
+export function LoadingPage({ text }: { text?: string }) {
+ return ;
+}
+
+/**
+ * Inline loading component for use within components
+ */
+export function LoadingInline({
+ size,
+ text,
+}: {
+ size?: 'sm' | 'default' | 'lg';
+ text?: string;
+}) {
+ return ;
+}
diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts
index c097eb4e..e988ad31 100644
--- a/web/src/i18n/locales/en-US.ts
+++ b/web/src/i18n/locales/en-US.ts
@@ -37,6 +37,7 @@ const enUS = {
enable: 'Enable',
name: 'Name',
description: 'Description',
+ icon: 'Icon',
close: 'Close',
deleteSuccess: 'Deleted successfully',
deleteError: 'Delete failed: ',
@@ -275,6 +276,10 @@ const enUS = {
allLevels: 'All Levels',
selectLevel: 'Select Level',
levelsSelected: 'levels selected',
+ viewDetailedLogs: 'View Detailed Logs',
+ viewDetails: 'Details',
+ collapse: 'Collapse',
+ imagesAttached: 'image(s) attached',
},
plugins: {
title: 'Extensions',
@@ -441,7 +446,7 @@ const enUS = {
downloadFailed: 'Download failed',
noReadme: 'This plugin does not provide README documentation',
description: 'Description',
- tags: 'Tags',
+ tagLabel: 'Tags',
submissionTitle: 'You have a plugin submission under review: {{name}}',
submissionPending: 'Your plugin submission is under review: {{name}}',
submissionApproved: 'Your plugin submission has been approved: {{name}}',
@@ -457,6 +462,13 @@ const enUS = {
allComponents: 'All Components',
requestPlugin: 'Request Plugin',
viewDetails: 'View Details',
+ tags: {
+ filterByTags: 'Filter by Tags',
+ selected: 'selected',
+ selectTags: 'Select Tags',
+ clearAll: 'Clear All',
+ noTags: 'No tags available',
+ },
},
mcp: {
title: 'MCP',
@@ -632,6 +644,12 @@ const enUS = {
showMarkdown: 'Show Markdown',
showRaw: 'Show Raw',
},
+ monitoring: {
+ title: 'Monitoring',
+ description:
+ 'View execution logs and errors for this pipeline (last 24 hours)',
+ detailedLogs: 'Detailed Logs',
+ },
},
knowledge: {
title: 'Knowledge',
@@ -801,6 +819,140 @@ const enUS = {
spaceEmailMismatch:
'Space login email does not match the local account email',
},
+ monitoring: {
+ title: 'Monitoring',
+ description: 'Monitor bot activities, LLM calls, and system performance',
+ overview: 'Overview',
+ totalMessages: 'Total Messages',
+ llmCallsCount: 'LLM Calls',
+ modelCallsCount: 'Model Calls',
+ successRate: 'Success Rate',
+ activeSessions: 'Active Sessions',
+ last24Hours: 'Last 24 hours',
+ filters: {
+ title: 'Filters',
+ bot: 'Bot',
+ pipeline: 'Pipeline',
+ allBots: 'All Bots',
+ selectBot: 'Select Bot',
+ allPipelines: 'All Pipelines',
+ selectPipeline: 'Select Pipeline',
+ loading: 'Loading...',
+ timeRange: 'Time Range',
+ customRange: 'Custom Range',
+ from: 'From',
+ to: 'To',
+ apply: 'Apply',
+ reset: 'Reset Filters',
+ lastHour: 'Last 1 hour',
+ last6Hours: 'Last 6 hours',
+ last24Hours: 'Last 24 hours',
+ last7Days: 'Last 7 days',
+ last30Days: 'Last 30 days',
+ },
+ tabs: {
+ messages: 'Message Records',
+ llmCalls: 'LLM Calls',
+ embeddingCalls: 'Embedding Calls',
+ modelCalls: 'Model Calls',
+ sessions: 'Session Analysis',
+ errors: 'Error Logs',
+ },
+ messageList: {
+ timestamp: 'Timestamp',
+ bot: 'Bot',
+ pipeline: 'Pipeline',
+ message: 'Message',
+ sessionId: 'Session ID',
+ status: 'Status',
+ actions: 'Actions',
+ viewDetails: 'View Details',
+ copyId: 'Copy ID',
+ noMessages: 'No messages found',
+ noMessagesDescription: 'Try adjusting your filters or check back later',
+ loading: 'Loading messages...',
+ loadMore: 'Load More',
+ autoRefresh: 'Auto Refresh',
+ platform: 'Role',
+ user: 'User',
+ level: 'Level',
+ runner: 'Runner',
+ viewConversation: 'View Conversation',
+ },
+ llmCalls: {
+ title: 'LLM Calls',
+ model: 'Model',
+ tokens: 'Tokens',
+ duration: 'Duration',
+ cost: 'Cost',
+ noData: 'No LLM calls found',
+ inputTokens: 'Input Tokens',
+ outputTokens: 'Output Tokens',
+ totalTokens: 'Total Tokens',
+ avgDuration: 'Avg Duration',
+ calls: 'Calls',
+ },
+ embeddingCalls: {
+ title: 'Embedding Calls',
+ model: 'Model',
+ tokens: 'Tokens',
+ duration: 'Duration',
+ noData: 'No embedding calls found',
+ promptTokens: 'Prompt Tokens',
+ totalTokens: 'Total Tokens',
+ inputCount: 'Input Count',
+ knowledgeBase: 'Knowledge Base',
+ queryText: 'Query',
+ },
+ modelCalls: {
+ title: 'Model Calls',
+ llmModel: 'LLM',
+ embeddingModel: 'Embedding',
+ embeddingCall: 'Embedding',
+ retrieveCall: 'Retrieve',
+ noData: 'No model calls found',
+ },
+ sessions: {
+ sessionId: 'Session ID',
+ messageCount: 'Messages',
+ duration: 'Duration',
+ lastActivity: 'Last Activity',
+ noSessions: 'No sessions found',
+ startTime: 'Start Time',
+ messageStats: 'Message Statistics',
+ totalMessages: 'Total Messages',
+ successMessages: 'Successful',
+ errorMessages: 'Failed',
+ llmStats: 'LLM Statistics',
+ noData: 'Session not found',
+ },
+ errors: {
+ title: 'Errors',
+ errorType: 'Error Type',
+ errorMessage: 'Error Message',
+ occurredAt: 'Occurred At',
+ noErrors: 'No errors found',
+ stackTrace: 'Stack Trace',
+ },
+ queries: {
+ title: 'Queries',
+ },
+ messageDetails: {
+ noData: 'No LLM calls or errors for this query',
+ },
+ queryVariables: {
+ title: 'Query Variables',
+ },
+ trafficChart: {
+ title: 'Traffic Overview',
+ messages: 'Messages',
+ llmCalls: 'LLM Calls',
+ noData: 'No traffic data available',
+ },
+ viewMonitoring: 'View Monitoring',
+ refreshData: 'Refresh Data',
+ exportData: 'Export Data',
+ },
};
export default enUS;
diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts
index 903e8ea5..64b63cca 100644
--- a/web/src/i18n/locales/ja-JP.ts
+++ b/web/src/i18n/locales/ja-JP.ts
@@ -38,6 +38,7 @@ const jaJP = {
enable: '有効にする',
name: '名前',
description: '説明',
+ icon: 'アイコン',
close: '閉じる',
deleteSuccess: '削除に成功しました',
deleteError: '削除に失敗しました:',
@@ -446,7 +447,7 @@ const jaJP = {
downloadFailed: 'ダウンロード失敗',
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
description: '説明',
- tags: 'タグ',
+ tagLabel: 'タグ',
submissionTitle: 'プラグインの提出が審査中です: {{name}}',
submissionPending: 'プラグインの提出が審査中です: {{name}}',
submissionApproved: 'プラグインの提出が承認されました: {{name}}',
@@ -461,6 +462,13 @@ const jaJP = {
filterByComponent: 'コンポーネント',
allComponents: '全部コンポーネント',
requestPlugin: 'プラグインをリクエスト',
+ tags: {
+ filterByTags: 'タグで絞り込み',
+ selected: '選択済み',
+ selectTags: 'タグを選択',
+ clearAll: 'クリア',
+ noTags: 'タグがありません',
+ },
viewDetails: '詳細を表示',
},
mcp: {
@@ -639,6 +647,11 @@ const jaJP = {
showMarkdown: 'Markdownで表示',
showRaw: '原文で表示',
},
+ monitoring: {
+ title: 'モニタリング',
+ description: 'このパイプラインの実行ログとエラー情報を表示(過去24時間)',
+ detailedLogs: '詳細ログ',
+ },
},
knowledge: {
title: '知識ベース',
@@ -810,6 +823,123 @@ const jaJP = {
spaceEmailMismatch:
'Spaceログインのメールアドレスがローカルアカウントのメールアドレスと一致しません',
},
+ monitoring: {
+ title: 'モニタリング',
+ description:
+ 'ボットアクティビティ、LLM呼び出し、システムパフォーマンスを監視',
+ overview: '概要',
+ totalMessages: '総メッセージ数',
+ llmCallsCount: 'LLM呼び出し',
+ modelCallsCount: 'モデル呼び出し',
+ successRate: '成功率',
+ activeSessions: 'アクティブセッション',
+ last24Hours: '過去24時間',
+ filters: {
+ title: 'フィルター',
+ bot: 'ボット',
+ pipeline: 'パイプライン',
+ allBots: 'すべてのボット',
+ selectBot: 'ボットを選択',
+ allPipelines: 'すべてのパイプライン',
+ selectPipeline: 'パイプラインを選択',
+ loading: '読み込み中...',
+ timeRange: '時間範囲',
+ customRange: 'カスタム範囲',
+ from: '開始',
+ to: '終了',
+ apply: '適用',
+ reset: 'フィルターをリセット',
+ lastHour: '過去1時間',
+ last6Hours: '過去6時間',
+ last24Hours: '過去24時間',
+ last7Days: '過去7日間',
+ last30Days: '過去30日間',
+ },
+ tabs: {
+ messages: 'メッセージ記録',
+ llmCalls: 'LLM呼び出し',
+ embeddingCalls: 'Embedding呼び出し',
+ modelCalls: 'モデル呼び出し',
+ sessions: 'セッション分析',
+ errors: 'エラーログ',
+ },
+ messageList: {
+ timestamp: 'タイムスタンプ',
+ bot: 'ボット',
+ pipeline: 'パイプライン',
+ message: 'メッセージ',
+ sessionId: 'セッションID',
+ status: 'ステータス',
+ actions: 'アクション',
+ viewDetails: '詳細を表示',
+ copyId: 'IDをコピー',
+ noMessages: 'メッセージが見つかりません',
+ noMessagesDescription: 'フィルターを調整するか、後で確認してください',
+ loading: 'メッセージを読み込んでいます...',
+ loadMore: 'もっと読み込む',
+ autoRefresh: '自動更新',
+ platform: 'プラットフォーム',
+ user: 'ユーザー',
+ level: 'レベル',
+ runner: 'ランナー',
+ viewConversation: '会話詳細を表示',
+ },
+ llmCalls: {
+ model: 'モデル',
+ tokens: 'トークン数',
+ duration: '期間',
+ cost: 'コスト',
+ noData: 'LLM呼び出し記録が見つかりません',
+ inputTokens: '入力トークン',
+ outputTokens: '出力トークン',
+ totalTokens: '合計トークン数',
+ },
+ embeddingCalls: {
+ title: 'Embedding呼び出し',
+ model: 'モデル',
+ tokens: 'トークン数',
+ duration: '期間',
+ noData: 'Embedding呼び出し記録が見つかりません',
+ promptTokens: '入力トークン',
+ totalTokens: '合計トークン数',
+ inputCount: '入力数',
+ knowledgeBase: 'ナレッジベース',
+ queryText: 'クエリ',
+ },
+ modelCalls: {
+ title: 'モデル呼び出し',
+ llmModel: '対話モデル',
+ embeddingModel: '埋め込みモデル',
+ embeddingCall: '埋め込み呼び出し',
+ retrieveCall: '検索呼び出し',
+ noData: 'モデル呼び出し記録が見つかりません',
+ },
+ sessions: {
+ sessionId: 'セッションID',
+ messageCount: 'メッセージ数',
+ duration: '期間',
+ lastActivity: '最終アクティビティ',
+ noSessions: 'セッションが見つかりません',
+ startTime: '開始時刻',
+ },
+ errors: {
+ errorType: 'エラータイプ',
+ errorMessage: 'エラーメッセージ',
+ occurredAt: '発生時刻',
+ noErrors: 'エラーが見つかりません',
+ stackTrace: 'スタックトレース',
+ title: 'エラー',
+ },
+ messageDetails: {
+ noData: 'このクエリにはLLM呼び出しやエラーがありません',
+ },
+ queryVariables: {
+ title: 'クエリ変数',
+ },
+ viewMonitoring: 'モニタリングを表示',
+ refreshData: 'データを更新',
+ exportData: 'データをエクスポート',
+ },
};
export default jaJP;
diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts
index 6ce7a13a..141f073d 100644
--- a/web/src/i18n/locales/zh-Hans.ts
+++ b/web/src/i18n/locales/zh-Hans.ts
@@ -37,6 +37,7 @@ const zhHans = {
enable: '是否启用',
name: '名称',
description: '描述',
+ icon: '图标',
close: '关闭',
deleteSuccess: '删除成功',
deleteError: '删除失败:',
@@ -264,6 +265,10 @@ const zhHans = {
allLevels: '全部级别',
selectLevel: '选择级别',
levelsSelected: '个级别已选',
+ viewDetailedLogs: '查看详细日志',
+ viewDetails: '详情',
+ collapse: '收起',
+ imagesAttached: '张图片',
},
plugins: {
title: '插件扩展',
@@ -420,7 +425,7 @@ const zhHans = {
downloadFailed: '下载失败',
noReadme: '该插件没有提供 README 文档',
description: '描述',
- tags: '标签',
+ tagLabel: '标签',
submissionTitle: '您有插件提交正在审核中: {{name}}',
submissionApproved: '您的插件提交已通过审核: {{name}}',
submissionRejected: '您的插件提交已被拒绝: {{name}}',
@@ -434,6 +439,13 @@ const zhHans = {
filterByComponent: '组件',
allComponents: '全部组件',
requestPlugin: '请求插件',
+ tags: {
+ filterByTags: '按标签筛选',
+ selected: '已选',
+ selectTags: '选择标签',
+ clearAll: '清空',
+ noTags: '暂无标签',
+ },
viewDetails: '查看详情',
},
mcp: {
@@ -608,6 +620,11 @@ const zhHans = {
showMarkdown: '渲染',
showRaw: '原文',
},
+ monitoring: {
+ title: '监控日志',
+ description: '查看此流水线的运行记录和错误信息(最近24小时)',
+ detailedLogs: '详细日志',
+ },
},
knowledge: {
title: '知识库',
@@ -728,7 +745,7 @@ const zhHans = {
},
llm: {
llmModels: '对话模型',
- description: '管理 LLM 模型,用于对话消息生成',
+ description: '管理 LLM 模型,用于对话消息生成',
extraParametersDescription:
'将在请求时附加到请求体中,如 max_tokens, temperature, top_p 等',
},
@@ -762,6 +779,140 @@ const zhHans = {
setPasswordHint: '设置密码后可使用邮箱密码登录',
spaceEmailMismatch: 'Space登录账号邮箱与本实例账号邮箱不匹配',
},
+ monitoring: {
+ title: '日志监控',
+ description: '查看机器人活动、LLM调用和系统性能',
+ overview: '概览',
+ totalMessages: '总消息数',
+ llmCallsCount: 'LLM调用',
+ modelCallsCount: '模型调用',
+ successRate: '成功率',
+ activeSessions: '活跃会话',
+ last24Hours: '最近24小时',
+ filters: {
+ title: '筛选',
+ bot: '机器人',
+ pipeline: '流水线',
+ allBots: '全部机器人',
+ selectBot: '选择机器人',
+ allPipelines: '全部流水线',
+ selectPipeline: '选择流水线',
+ loading: '加载中...',
+ timeRange: '时间范围',
+ customRange: '自定义范围',
+ from: '从',
+ to: '到',
+ apply: '应用',
+ reset: '重置筛选',
+ lastHour: '最近1小时',
+ last6Hours: '最近6小时',
+ last24Hours: '最近24小时',
+ last7Days: '最近7天',
+ last30Days: '最近30天',
+ },
+ tabs: {
+ messages: '消息记录',
+ llmCalls: 'LLM调用',
+ embeddingCalls: 'Embedding调用',
+ modelCalls: '模型调用',
+ sessions: '会话分析',
+ errors: '错误日志',
+ },
+ messageList: {
+ timestamp: '时间戳',
+ bot: '机器人',
+ pipeline: '流水线',
+ message: '消息',
+ sessionId: '会话ID',
+ status: '状态',
+ actions: '操作',
+ viewDetails: '查看详情',
+ copyId: '复制ID',
+ noMessages: '未找到消息',
+ noMessagesDescription: '尝试调整筛选条件或稍后查看',
+ loading: '加载消息中...',
+ loadMore: '加载更多',
+ autoRefresh: '自动刷新',
+ platform: '角色',
+ user: '用户',
+ level: '级别',
+ runner: '执行器',
+ viewConversation: '显示对话详情',
+ },
+ llmCalls: {
+ title: 'LLM调用',
+ model: '模型',
+ tokens: 'Token数',
+ duration: '持续时间',
+ cost: '成本',
+ noData: '未找到LLM调用记录',
+ inputTokens: '输入Token',
+ outputTokens: '输出Token',
+ totalTokens: '总Token数',
+ avgDuration: '平均耗时',
+ calls: '调用次数',
+ },
+ embeddingCalls: {
+ title: 'Embedding调用',
+ model: '模型',
+ tokens: 'Token数',
+ duration: '持续时间',
+ noData: '未找到Embedding调用记录',
+ promptTokens: '输入Token',
+ totalTokens: '总Token数',
+ inputCount: '输入数量',
+ knowledgeBase: '知识库',
+ queryText: '查询文本',
+ },
+ modelCalls: {
+ title: '模型调用',
+ llmModel: '对话模型',
+ embeddingModel: '嵌入模型',
+ embeddingCall: '嵌入调用',
+ retrieveCall: '检索调用',
+ noData: '未找到模型调用记录',
+ },
+ sessions: {
+ sessionId: '会话ID',
+ messageCount: '消息数',
+ duration: '持续时间',
+ lastActivity: '最后活动',
+ noSessions: '未找到会话',
+ startTime: '开始时间',
+ messageStats: '消息统计',
+ totalMessages: '总消息数',
+ successMessages: '成功',
+ errorMessages: '失败',
+ llmStats: 'LLM统计',
+ noData: '会话未找到',
+ },
+ errors: {
+ title: '错误',
+ errorType: '错误类型',
+ errorMessage: '错误消息',
+ occurredAt: '发生时间',
+ noErrors: '未找到错误',
+ stackTrace: '堆栈追踪',
+ },
+ queries: {
+ title: '查询记录',
+ },
+ messageDetails: {
+ noData: '此查询没有LLM调用或错误记录',
+ },
+ queryVariables: {
+ title: '查询变量',
+ },
+ trafficChart: {
+ title: '流量概览',
+ messages: '消息数',
+ llmCalls: 'LLM调用',
+ noData: '暂无流量数据',
+ },
+ viewMonitoring: '查看日志监控',
+ refreshData: '刷新数据',
+ exportData: '导出数据',
+ },
};
export default zhHans;
diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts
index 2dac2873..6f9266c3 100644
--- a/web/src/i18n/locales/zh-Hant.ts
+++ b/web/src/i18n/locales/zh-Hant.ts
@@ -37,6 +37,7 @@ const zhHant = {
enable: '是否啟用',
name: '名稱',
description: '描述',
+ icon: '圖標',
close: '關閉',
deleteSuccess: '刪除成功',
deleteError: '刪除失敗:',
@@ -417,7 +418,7 @@ const zhHant = {
downloadFailed: '下載失敗',
noReadme: '該插件沒有提供 README 文件',
description: '描述',
- tags: '標籤',
+ tagLabel: '標籤',
submissionTitle: '您有插件提交正在審核中: {{name}}',
submissionApproved: '您的插件提交已通過審核: {{name}}',
submissionRejected: '您的插件提交已被拒絕: {{name}}',
@@ -431,6 +432,13 @@ const zhHant = {
filterByComponent: '組件',
allComponents: '全部組件',
requestPlugin: '請求插件',
+ tags: {
+ filterByTags: '按標籤篩選',
+ selected: '已選',
+ selectTags: '選擇標籤',
+ clearAll: '清空',
+ noTags: '暫無標籤',
+ },
viewDetails: '查看詳情',
},
mcp: {
@@ -605,6 +613,11 @@ const zhHant = {
showMarkdown: '渲染',
showRaw: '原文',
},
+ monitoring: {
+ title: '監控日誌',
+ description: '檢視此流程線的執行記錄和錯誤資訊(最近24小時)',
+ detailedLogs: '詳細日誌',
+ },
},
knowledge: {
title: '知識庫',
@@ -759,6 +772,122 @@ const zhHant = {
setPasswordHint: '設定密碼後可使用電子郵件密碼登入',
spaceEmailMismatch: 'Space登入帳號電子郵件與本實例帳號電子郵件不匹配',
},
+ monitoring: {
+ title: '日誌監控',
+ description: '監控機器人活動、LLM調用和系統效能',
+ overview: '概覽',
+ totalMessages: '總訊息數',
+ llmCallsCount: 'LLM調用',
+ modelCallsCount: '模型調用',
+ successRate: '成功率',
+ activeSessions: '活躍會話',
+ last24Hours: '最近24小時',
+ filters: {
+ title: '篩選',
+ bot: '機器人',
+ pipeline: '流水線',
+ allBots: '全部機器人',
+ selectBot: '選擇機器人',
+ allPipelines: '全部流水線',
+ selectPipeline: '選擇流水線',
+ loading: '載入中...',
+ timeRange: '時間範圍',
+ customRange: '自訂範圍',
+ from: '從',
+ to: '到',
+ apply: '套用',
+ reset: '重置篩選',
+ lastHour: '最近1小時',
+ last6Hours: '最近6小時',
+ last24Hours: '最近24小時',
+ last7Days: '最近7天',
+ last30Days: '最近30天',
+ },
+ tabs: {
+ messages: '訊息記錄',
+ llmCalls: 'LLM調用',
+ embeddingCalls: 'Embedding調用',
+ modelCalls: '模型調用',
+ sessions: '會話分析',
+ errors: '錯誤日誌',
+ },
+ messageList: {
+ timestamp: '時間戳記',
+ bot: '機器人',
+ pipeline: '流水線',
+ message: '訊息',
+ sessionId: '會話ID',
+ status: '狀態',
+ actions: '操作',
+ viewDetails: '查看詳情',
+ copyId: '複製ID',
+ noMessages: '未找到訊息',
+ noMessagesDescription: '嘗試調整篩選條件或稍後查看',
+ loading: '載入訊息中...',
+ loadMore: '載入更多',
+ autoRefresh: '自動重新整理',
+ platform: '平台',
+ user: '使用者',
+ level: '級別',
+ runner: '執行器',
+ viewConversation: '顯示對話詳情',
+ },
+ llmCalls: {
+ model: '模型',
+ tokens: '代幣數',
+ duration: '持續時間',
+ cost: '成本',
+ noData: '未找到LLM調用記錄',
+ inputTokens: '輸入代幣',
+ outputTokens: '輸出代幣',
+ totalTokens: '總代幣數',
+ },
+ embeddingCalls: {
+ title: 'Embedding調用',
+ model: '模型',
+ tokens: '代幣數',
+ duration: '持續時間',
+ noData: '未找到Embedding調用記錄',
+ promptTokens: '輸入代幣',
+ totalTokens: '總代幣數',
+ inputCount: '輸入數量',
+ knowledgeBase: '知識庫',
+ queryText: '查詢文字',
+ },
+ modelCalls: {
+ title: '模型調用',
+ llmModel: '對話模型',
+ embeddingModel: '嵌入模型',
+ embeddingCall: '嵌入調用',
+ retrieveCall: '檢索調用',
+ noData: '未找到模型調用記錄',
+ },
+ sessions: {
+ sessionId: '會話ID',
+ messageCount: '訊息數',
+ duration: '持續時間',
+ lastActivity: '最後活動',
+ noSessions: '未找到會話',
+ startTime: '開始時間',
+ },
+ errors: {
+ errorType: '錯誤類型',
+ errorMessage: '錯誤訊息',
+ occurredAt: '發生時間',
+ noErrors: '未找到錯誤',
+ stackTrace: '堆疊追蹤',
+ title: '錯誤',
+ },
+ messageDetails: {
+ noData: '此查詢沒有LLM調用或錯誤記錄',
+ },
+ queryVariables: {
+ title: '查詢變數',
+ },
+ viewMonitoring: '查看日誌監控',
+ refreshData: '重新整理資料',
+ exportData: '匯出資料',
+ },
};
export default zhHant;
diff --git a/web/tsconfig.json b/web/tsconfig.json
index c1334095..b575f7da 100644
--- a/web/tsconfig.json
+++ b/web/tsconfig.json
@@ -1,7 +1,11 @@
{
"compilerOptions": {
"target": "ES2017",
- "lib": ["dom", "dom.iterable", "esnext"],
+ "lib": [
+ "dom",
+ "dom.iterable",
+ "esnext"
+ ],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -11,7 +15,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -19,9 +23,19 @@
}
],
"paths": {
- "@/*": ["./src/*"]
+ "@/*": [
+ "./src/*"
+ ]
}
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
- "exclude": ["node_modules"]
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
+ "exclude": [
+ "node_modules"
+ ]
}