Merge remote-tracking branch 'origin/master' into dev/4.11.x

# Conflicts:
#	pyproject.toml
#	uv.lock
#	web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx
This commit is contained in:
Junyan Qin
2026-07-03 20:46:19 +08:00
52 changed files with 4826 additions and 739 deletions
+41 -39
View File
@@ -191,45 +191,47 @@ export default function BotDetailContent({ id }: { id: string }) {
onValueChange={setActiveTab}
className="flex flex-1 flex-col min-h-0"
>
<TabsList className="shrink-0">
<TabsTrigger value="config" className="gap-1.5">
<Settings className="size-3.5" />
{t('bots.configuration')}
</TabsTrigger>
<TabsTrigger value="logs" className="gap-1.5">
<FileText className="size-3.5" />
{t('bots.logs')}
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" />
{t('bots.sessionMonitor.title')}
{activeTab === 'sessions' && (
<button
type="button"
className="inline-flex items-center justify-center ml-0.5"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
if (isRefreshingSessions) return;
setIsRefreshingSessions(true);
const minDelay = new Promise((r) => setTimeout(r, 500));
Promise.all([
sessionMonitorRef.current?.refreshSessions(),
minDelay,
]).finally(() => setIsRefreshingSessions(false));
}}
>
<RefreshCw
className={cn(
'size-3 text-muted-foreground hover:text-foreground transition-colors',
isRefreshingSessions && 'animate-spin',
)}
/>
</button>
)}
</TabsTrigger>
</TabsList>
<div className="flex shrink-0 items-center gap-1">
<TabsList>
<TabsTrigger value="config" className="gap-1.5">
<Settings className="size-3.5" />
{t('bots.configuration')}
</TabsTrigger>
<TabsTrigger value="logs" className="gap-1.5">
<FileText className="size-3.5" />
{t('bots.logs')}
</TabsTrigger>
<TabsTrigger value="sessions" className="gap-1.5">
<Users className="size-3.5" />
{t('bots.sessionMonitor.title')}
</TabsTrigger>
</TabsList>
{activeTab === 'sessions' && (
<button
type="button"
aria-label={t('bots.sessionMonitor.refresh')}
title={t('bots.sessionMonitor.refresh')}
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
disabled={isRefreshingSessions}
onClick={() => {
if (isRefreshingSessions) return;
setIsRefreshingSessions(true);
const minDelay = new Promise((r) => setTimeout(r, 500));
Promise.all([
sessionMonitorRef.current?.refreshSessions(),
minDelay,
]).finally(() => setIsRefreshingSessions(false));
}}
>
<RefreshCw
className={cn(
'size-3.5',
isRefreshingSessions && 'animate-spin',
)}
/>
</button>
)}
</div>
{/* Tab: Configuration */}
<TabsContent
@@ -3,6 +3,7 @@ import React, {
useEffect,
useRef,
useCallback,
useMemo,
forwardRef,
useImperativeHandle,
} from 'react';
@@ -15,11 +16,14 @@ import {
Bot,
Copy,
Check,
ChevronDown,
ChevronRight,
Workflow,
ThumbsUp,
ThumbsDown,
ShieldCheck,
ShieldOff,
Wrench,
} from 'lucide-react';
import { toast } from 'sonner';
import BotAdminsDialog, {
@@ -76,6 +80,35 @@ interface SessionFeedback {
stream_id?: string | null;
}
interface SessionToolCall {
id: string;
timestamp: string;
tool_name: string;
tool_source: string;
duration: number;
status: string;
message_id?: string | null;
arguments?: string | null;
result?: string | null;
error_message?: string | null;
}
type SessionTimelineItem =
| {
id: string;
type: 'message';
timestamp: number;
order: number;
message: SessionMessage;
}
| {
id: string;
type: 'tool';
timestamp: number;
order: number;
toolCall: SessionToolCall;
};
export interface BotSessionMonitorHandle {
refreshSessions: () => Promise<void>;
}
@@ -100,6 +133,10 @@ const BotSessionMonitor = forwardRef<
const [feedbackMap, setFeedbackMap] = useState<
Record<string, SessionFeedback>
>({});
const [toolCalls, setToolCalls] = useState<SessionToolCall[]>([]);
const [expandedToolCallIds, setExpandedToolCallIds] = useState<
Record<string, boolean>
>({});
const messagesContainerRef = useRef<HTMLDivElement>(null);
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
@@ -189,6 +226,7 @@ const BotSessionMonitor = forwardRef<
const loadMessages = useCallback(
async (sessionId: string) => {
setLoadingMessages(true);
setExpandedToolCallIds({});
try {
const messagesRes = await httpClient.getSessionMessages(sessionId);
const sorted = (messagesRes.messages ?? []).sort(
@@ -197,6 +235,18 @@ const BotSessionMonitor = forwardRef<
);
setMessages(sorted);
try {
const analysisRes = await httpClient.get<{
tool_calls?: SessionToolCall[];
}>(
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`,
);
setToolCalls(analysisRes?.tool_calls ?? []);
} catch (analysisError) {
console.error('Failed to load session tool calls:', analysisError);
setToolCalls([]);
}
// Collect user message IDs for feedback matching
const userMsgIds = new Set(
sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id),
@@ -240,11 +290,14 @@ const BotSessionMonitor = forwardRef<
loadMessages(selectedSessionId);
} else {
setMessages([]);
setToolCalls([]);
setExpandedToolCallIds({});
setFeedbackMap({});
}
}, [selectedSessionId, loadMessages]);
useEffect(() => {
if (messages.length === 0) return;
if (messages.length === 0 && toolCalls.length === 0) return;
// Wait for DOM to render the new messages before scrolling
requestAnimationFrame(() => {
const container = messagesContainerRef.current;
@@ -256,7 +309,7 @@ const BotSessionMonitor = forwardRef<
scrollTarget.scrollTop = scrollTarget.scrollHeight;
}
});
}, [messages]);
}, [messages, toolCalls]);
const parseMessageChain = (content: string): MessageChainComponent[] => {
try {
@@ -431,6 +484,71 @@ const BotSessionMonitor = forwardRef<
return `${diffDays}d`;
};
const formatDuration = (durationMs: number): string => {
if (!durationMs) return '0ms';
if (durationMs < 1000) return `${durationMs}ms`;
return `${(durationMs / 1000).toFixed(2)}s`;
};
const truncateToolDetail = (value?: string | null): string => {
if (!value) return '';
return value.length > 600 ? `${value.slice(0, 600)}...` : value;
};
const toggleToolCallDetails = (toolCallId: string) => {
setExpandedToolCallIds((previous) => ({
...previous,
[toolCallId]: !previous[toolCallId],
}));
};
const feedbackByMessageId = useMemo(() => {
const map: Record<string, SessionFeedback> = {};
for (let index = 0; index < messages.length; index++) {
const msg = messages[index];
if (isUserMessage(msg)) continue;
for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) {
const previousMessage = messages[previousIndex];
if (isUserMessage(previousMessage)) {
const feedback = feedbackMap[previousMessage.id];
if (feedback) {
map[msg.id] = feedback;
}
break;
}
}
}
return map;
}, [feedbackMap, messages]);
const timelineItems = useMemo<SessionTimelineItem[]>(() => {
const messageItems: SessionTimelineItem[] = messages.map(
(message, index) => ({
id: `message-${message.id}`,
type: 'message',
timestamp: parseTimestamp(message.timestamp).getTime(),
order: index * 2,
message,
}),
);
const toolItems: SessionTimelineItem[] = toolCalls.map(
(toolCall, index) => ({
id: `tool-${toolCall.id}`,
type: 'tool',
timestamp: parseTimestamp(toolCall.timestamp).getTime(),
order: index * 2 + 1,
toolCall,
}),
);
return [...messageItems, ...toolItems].sort(
(a, b) => a.timestamp - b.timestamp || a.order - b.order,
);
}, [messages, toolCalls]);
const selectedSession = sessions.find(
(s) => s.session_id === selectedSessionId,
);
@@ -612,29 +730,162 @@ const BotSessionMonitor = forwardRef<
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.loading')}
</div>
) : messages.length === 0 ? (
) : timelineItems.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noMessages')}
</div>
) : (
messages.map((msg, msgIndex) => {
timelineItems.map((item) => {
if (item.type === 'tool') {
const call = item.toolCall;
const hasToolDetails = Boolean(
call.arguments || call.result || call.error_message,
);
const expandedToolCall = Boolean(
expandedToolCallIds[call.id],
);
const detailsId = `tool-call-details-${call.id}`;
return (
<div key={item.id} className="flex justify-start">
<div className="max-w-2xl rounded-xl rounded-bl-sm border border-border/60 bg-muted/25 px-2.5 py-1.5 text-xs text-muted-foreground">
<button
type="button"
className={cn(
'flex w-full items-center justify-between gap-3 rounded-md text-left outline-none transition-colors',
hasToolDetails &&
'cursor-pointer hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring',
)}
aria-expanded={
hasToolDetails ? expandedToolCall : undefined
}
aria-controls={
hasToolDetails ? detailsId : undefined
}
aria-disabled={!hasToolDetails}
onClick={() =>
hasToolDetails &&
toggleToolCallDetails(call.id)
}
>
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{hasToolDetails &&
(expandedToolCall ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
))}
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
<span className="min-w-0 max-w-[18rem] truncate text-[13px] font-medium text-foreground/75">
{call.tool_name}
</span>
<span className="rounded border border-border/50 bg-background/60 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
{call.tool_source}
</span>
<span
className={cn(
'rounded px-1.5 py-0.5 text-[10px] font-medium leading-none',
call.status === 'success'
? 'bg-green-100/70 text-green-700 dark:bg-green-950/60 dark:text-green-300'
: 'bg-red-100/70 text-red-700 dark:bg-red-950/60 dark:text-red-300',
)}
>
{call.status}
</span>
</div>
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground/80">
{formatDuration(call.duration)}
</span>
</button>
{hasToolDetails && expandedToolCall && (
<div
id={detailsId}
className="mt-2 space-y-1.5"
>
{(call.arguments || call.result) && (
<div className="space-y-1.5">
{call.arguments && (
<div>
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
{t(
'monitoring.toolCalls.arguments',
{
defaultValue: '参数',
},
)}
</div>
<pre className="whitespace-pre-wrap break-words rounded bg-background/80 p-2 font-mono text-[11px] leading-4 text-muted-foreground">
{truncateToolDetail(call.arguments)}
</pre>
</div>
)}
{call.result && (
<div>
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
{t('monitoring.toolCalls.result', {
defaultValue: '结果',
})}
</div>
<pre className="whitespace-pre-wrap break-words rounded bg-background/80 p-2 font-mono text-[11px] leading-4 text-muted-foreground">
{truncateToolDetail(call.result)}
</pre>
</div>
)}
</div>
)}
{call.error_message && (
<div className="whitespace-pre-wrap break-words rounded bg-red-50 p-2 text-[11px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
{call.error_message}
</div>
)}
</div>
)}
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span>
{t('monitoring.toolCalls.title', {
defaultValue: '工具调用',
})}
</span>
<span className="tabular-nums">
{formatTime(call.timestamp)}
</span>
{hasToolDetails && (
<>
<span>·</span>
<span>
{expandedToolCall
? t(
'monitoring.toolCalls.hideDetails',
{
defaultValue: '隐藏详情',
},
)
: t(
'monitoring.toolCalls.showDetails',
{
defaultValue: '查看详情',
},
)}
</span>
</>
)}
</div>
</div>
</div>
);
}
const msg = item.message;
const isUser = isUserMessage(msg);
const isDiscarded =
msg.status === 'discarded' ||
msg.pipeline_id === PIPELINE_DISCARD;
// For bot replies, find feedback linked to the preceding user message
let msgFeedback: SessionFeedback | undefined;
if (!isUser) {
for (let i = msgIndex - 1; i >= 0; i--) {
if (isUserMessage(messages[i])) {
msgFeedback = feedbackMap[messages[i].id];
break;
}
}
}
const msgFeedback = feedbackByMessageId[msg.id];
return (
<div
key={msg.id}
key={item.id}
className={cn(
'flex',
isUser ? 'justify-end' : 'justify-start',
@@ -105,6 +105,16 @@ function SelectOptionContent({
);
}
function hasUsableUuid<T extends { uuid?: string | null }>(
item: T,
): item is T & { uuid: string } {
return typeof item.uuid === 'string' && item.uuid.trim().length > 0;
}
function hasUsableOptionName(option: { name?: string | null }): boolean {
return typeof option.name === 'string' && option.name.trim().length > 0;
}
export default function DynamicFormItemComponent({
config,
field,
@@ -142,7 +152,7 @@ export default function DynamicFormItemComponent({
httpClient
.getProviderLLMModels()
.then((resp) => {
setLlmModels(resp.models);
setLlmModels(resp.models.filter(hasUsableUuid));
})
.catch((err) => {
toast.error(t('models.getModelListError') + err.msg);
@@ -153,7 +163,7 @@ export default function DynamicFormItemComponent({
httpClient
.getProviderEmbeddingModels()
.then((resp) => {
setEmbeddingModels(resp.models);
setEmbeddingModels(resp.models.filter(hasUsableUuid));
})
.catch((err) => {
toast.error(t('embedding.getModelListError') + err.msg);
@@ -164,7 +174,7 @@ export default function DynamicFormItemComponent({
httpClient
.getProviderRerankModels()
.then((resp) => {
setRerankModels(resp.models);
setRerankModels(resp.models.filter(hasUsableUuid));
})
.catch((err) => {
toast.error('Failed to load rerank models: ' + err.msg);
@@ -268,7 +278,7 @@ export default function DynamicFormItemComponent({
httpClient
.getKnowledgeBases()
.then((resp) => {
setKnowledgeBases(resp.bases);
setKnowledgeBases(resp.bases.filter(hasUsableUuid));
})
.catch((err) => {
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
@@ -281,7 +291,7 @@ export default function DynamicFormItemComponent({
httpClient
.getBots()
.then((resp) => {
setBots(resp.bots);
setBots(resp.bots.filter(hasUsableUuid));
})
.catch((err) => {
toast.error(t('bots.getBotListError') + err.msg);
@@ -461,7 +471,7 @@ export default function DynamicFormItemComponent({
</SelectTrigger>
<SelectContent>
<SelectGroup>
{config.options?.map((option) => (
{config.options?.filter(hasUsableOptionName).map((option) => (
<SelectItem
key={option.name}
value={option.name}
@@ -1252,7 +1262,8 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
// Group KBs by Knowledge Engine name
const kbsByEngine = knowledgeBases.reduce(
const validKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
const kbsByEngine = validKnowledgeBases.reduce(
(acc, kb) => {
const engineName = kb.knowledge_engine?.name
? extractI18nObject(kb.knowledge_engine.name)
@@ -1263,7 +1274,7 @@ export default function DynamicFormItemComponent({
acc[engineName].push(kb);
return acc;
},
{} as Record<string, typeof knowledgeBases>,
{} as Record<string, typeof validKnowledgeBases>,
);
return (
@@ -1271,7 +1282,7 @@ export default function DynamicFormItemComponent({
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
{field.value && field.value !== '__none__' ? (
(() => {
const selectedKb = knowledgeBases.find(
const selectedKb = validKnowledgeBases.find(
(kb) => kb.uuid === field.value,
);
return (
@@ -1300,7 +1311,7 @@ export default function DynamicFormItemComponent({
<SelectGroup key={engineName}>
<SelectLabel>{engineName}</SelectLabel>
{kbs.map((base) => (
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
<SelectItem key={base.uuid} value={base.uuid}>
<div className="flex items-center gap-2">
{base.emoji && (
<span className="text-sm shrink-0">{base.emoji}</span>
@@ -1317,7 +1328,8 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
// Group KBs by Knowledge Engine name for multi-selector
const multiKbsByEngine = knowledgeBases.reduce(
const validMultiKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
const multiKbsByEngine = validMultiKnowledgeBases.reduce(
(acc, kb) => {
const engineName = kb.knowledge_engine?.name
? extractI18nObject(kb.knowledge_engine.name)
@@ -1328,7 +1340,7 @@ export default function DynamicFormItemComponent({
acc[engineName].push(kb);
return acc;
},
{} as Record<string, typeof knowledgeBases>,
{} as Record<string, typeof validMultiKnowledgeBases>,
);
return (
@@ -1337,7 +1349,7 @@ export default function DynamicFormItemComponent({
{field.value && field.value.length > 0 ? (
<div className="min-w-0 space-y-2">
{field.value.map((kbId: string) => {
const currentKb = knowledgeBases.find(
const currentKb = validMultiKnowledgeBases.find(
(base) => base.uuid === kbId,
);
if (!currentKb) return null;
@@ -1423,15 +1435,13 @@ export default function DynamicFormItemComponent({
{engineName}
</div>
{kbs.map((base) => {
const isSelected = tempSelectedKBIds.includes(
base.uuid ?? '',
);
const isSelected = tempSelectedKBIds.includes(base.uuid);
return (
<div
key={base.uuid}
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
onClick={() => {
const kbId = base.uuid ?? '';
const kbId = base.uuid;
setTempSelectedKBIds((prev) =>
prev.includes(kbId)
? prev.filter((id) => id !== kbId)
@@ -1493,8 +1503,8 @@ export default function DynamicFormItemComponent({
</SelectTrigger>
<SelectContent>
<SelectGroup>
{bots.map((bot) => (
<SelectItem key={bot.uuid} value={bot.uuid ?? ''}>
{bots.filter(hasUsableUuid).map((bot) => (
<SelectItem key={bot.uuid} value={bot.uuid}>
{bot.name}
</SelectItem>
))}
@@ -268,6 +268,48 @@ function saveListExpansionState(state: SidebarListExpansionState) {
// Maximum number of entity sub-items visible before "More" toggle
const MAX_VISIBLE_ITEMS = 5;
const MCP_REFRESH_POLL_INTERVAL_MS = 1000;
const MCP_REFRESH_TIMEOUT_MS = 60000;
function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForMCPRefreshTask(taskId: number) {
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
while (Date.now() < deadline) {
const task = await httpClient.getAsyncTask(taskId);
if (task.runtime.done) return task;
await sleep(MCP_REFRESH_POLL_INTERVAL_MS);
}
throw new Error(`Timed out waiting for MCP refresh task ${taskId}`);
}
async function refreshEnabledMCPConnections() {
const resp = await httpClient.getMCPServers();
const enabledServers = resp.servers.filter((server) => server.enable);
if (enabledServers.length === 0) return;
const taskResults = await Promise.allSettled(
enabledServers.map((server) => httpClient.testMCPServer(server.name, {})),
);
const taskIds: number[] = [];
for (const result of taskResults) {
if (
result.status === 'fulfilled' &&
typeof result.value.task_id === 'number'
) {
taskIds.push(result.value.task_id);
} else if (result.status === 'rejected') {
console.error('Failed to start MCP refresh task:', result.reason);
}
}
await Promise.allSettled(taskIds.map(waitForMCPRefreshTask));
}
// Sort entity items by updatedAt descending (most recent first), items without updatedAt go last
function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] {
@@ -356,11 +398,19 @@ function NavItems({
if (extRefreshing) return;
setExtRefreshing(true);
try {
await Promise.all([
const results = await Promise.allSettled([
sidebarData.refreshPlugins(),
sidebarData.refreshMCPServers(),
sidebarData.refreshSkills(),
refreshEnabledMCPConnections(),
]);
const mcpRefreshResult = results[2];
if (mcpRefreshResult.status === 'rejected') {
console.error(
'Failed to refresh MCP connections:',
mcpRefreshResult.reason,
);
}
await sidebarData.refreshMCPServers();
} finally {
setExtRefreshing(false);
}
@@ -157,6 +157,10 @@ export default function MCPDetailContent({ id }: { id: string }) {
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
}
const handlePersistedTestComplete = useCallback(async () => {
await refreshMCPServers();
}, [refreshMCPServers]);
function confirmDelete() {
httpClient
.deleteMCPServer(id)
@@ -364,6 +368,7 @@ export default function MCPDetailContent({ id }: { id: string }) {
onRuntimeInfoChange={(runtimeInfo) =>
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
}
onPersistedTestComplete={handlePersistedTestComplete}
/>
</div>
</div>
@@ -41,6 +41,7 @@ import {
} from '@/components/ui/card';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import MCPLogs from '@/app/home/mcp/components/mcp-form/MCPLogs';
import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme';
import {
MCPServerRuntimeInfo,
@@ -487,6 +488,7 @@ interface MCPFormProps {
onDirtyChange?: (dirty: boolean) => void;
onTestingChange?: (testing: boolean) => void;
onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
onPersistedTestComplete?: (serverName: string) => void | Promise<void>;
/** Reported when the form cannot be saved because the current mode is
* ``stdio`` and the Box sandbox is disabled/unavailable. Parents that
* render the Save button outside this component should disable it. */
@@ -511,6 +513,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
onDirtyChange,
onTestingChange,
onRuntimeInfoChange,
onPersistedTestComplete,
onSaveBlockedChange,
layout = 'stacked',
sideHeader,
@@ -750,6 +753,8 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
}
try {
let serverConfig: MCPServer;
const serverName =
isEditMode && initServerName ? initServerName : value.name;
if (value.mode === 'remote') {
const headers: Record<string, string> = {};
@@ -758,7 +763,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
});
serverConfig = {
name: value.name,
name: serverName,
mode: 'remote',
enable: true,
extra_args: {
@@ -774,7 +779,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
});
serverConfig = {
name: value.name,
name: serverName,
mode: 'stdio',
enable: true,
extra_args: {
@@ -818,6 +823,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
// `uvx` with no package (exit 2 / "Connection closed", no detail).
// The form values are kept in sync on every edit and on load, so they
// are always current.
const serverName =
isEditMode && initServerName ? initServerName : form.getValues('name');
const shouldTestPersistedServer =
isEditMode && !!initServerName && !form.formState.isDirty;
const formExtraArgs = form.getValues('extra_args') ?? [];
const formStdioArgs = form.getValues('args') ?? [];
let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio;
@@ -840,12 +849,20 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
};
}
const { task_id } = await httpClient.testMCPServer('_', {
name: form.getValues('name'),
mode,
enable: true,
extra_args: extraArgsData,
} as MCPServer);
const testTarget = shouldTestPersistedServer ? serverName : '_';
const testPayload = shouldTestPersistedServer
? {}
: ({
name: serverName,
mode,
enable: true,
extra_args: extraArgsData,
} as MCPServer);
const { task_id } = await httpClient.testMCPServer(
testTarget,
testPayload,
);
if (!task_id) {
throw new Error(t('mcp.noTaskId'));
@@ -871,14 +888,18 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
resource_count: 0,
resources: [],
});
if (shouldTestPersistedServer) {
await onPersistedTestComplete?.(serverName);
}
} else {
if (isEditMode) {
await loadServerForEdit(form.getValues('name'));
if (shouldTestPersistedServer) {
await loadServerForEdit(serverName);
await onPersistedTestComplete?.(serverName);
} else {
// Create mode has no persisted server to reload tools from.
// Transient tests have no persisted server to reload tools from.
// The backend stashes the discovered runtime info (status +
// tools) in the test task's metadata before tearing the
// transient session down — surface it so a successful test
// tools) in the task metadata before tearing the transient
// session down — surface it so a successful test
// shows the tool list instead of "no tools found".
const runtimeInfoFromTest = taskResp.task_context?.metadata
?.runtime_info as MCPServerRuntimeInfo | undefined;
@@ -1163,11 +1184,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
</Card>
);
const persistedServerName =
isEditMode && initServerName ? initServerName : form.getValues('name');
const runtimePanel = (
<RuntimePanel
mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo}
serverName={form.getValues('name')}
serverName={persistedServerName}
t={t}
/>
);
@@ -1200,6 +1224,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
<TabsTrigger value="resources" className="flex-none px-4">
{resourcesTabLabel}
</TabsTrigger>
<TabsTrigger value="logs" className="flex-none px-4">
{t('mcp.tabLogs')}
</TabsTrigger>
</TabsList>
<TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
<MCPReadme readme={readme} />
@@ -1211,7 +1238,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
<RuntimePanel
mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo}
serverName={form.getValues('name')}
serverName={persistedServerName}
content="tools"
t={t}
/>
@@ -1223,11 +1250,14 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
<RuntimePanel
mcpTesting={mcpTesting}
runtimeInfo={runtimeInfo}
serverName={form.getValues('name')}
serverName={persistedServerName}
content="resources"
t={t}
/>
</TabsContent>
<TabsContent value="logs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
{persistedServerName && <MCPLogs serverName={persistedServerName} />}
</TabsContent>
</Tabs>
) : (
runtimePanel
@@ -0,0 +1,149 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useTranslation } from 'react-i18next';
import { PluginLogEntry } from '@/app/infra/entities/plugin';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { RefreshCw } from 'lucide-react';
const LEVEL_OPTIONS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
function levelClassName(level: string): string {
switch (level) {
case 'ERROR':
case 'CRITICAL':
return 'text-red-500';
case 'WARNING':
return 'text-amber-500';
case 'DEBUG':
return 'text-gray-400 dark:text-gray-500';
default:
return 'text-gray-700 dark:text-gray-300';
}
}
export default function MCPLogs({ serverName }: { serverName: string }) {
const { t } = useTranslation();
const [logs, setLogs] = useState<PluginLogEntry[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [level, setLevel] = useState<string>('ALL');
const [autoRefresh, setAutoRefresh] = useState(true);
const scrollRef = useRef<HTMLDivElement>(null);
const atBottomRef = useRef(true);
const fetchLogs = useCallback(() => {
setIsLoading(true);
httpClient
.getMcpServerLogs(serverName, 500, level === 'ALL' ? undefined : level)
.then((res) => {
setLogs(res.logs ?? []);
})
.catch(() => {
setLogs([]);
})
.finally(() => {
setIsLoading(false);
});
}, [serverName, level]);
useEffect(() => {
fetchLogs();
}, [fetchLogs]);
// Auto-refresh poll loop.
useEffect(() => {
if (!autoRefresh) return;
const timer = setInterval(fetchLogs, 3000);
return () => clearInterval(timer);
}, [autoRefresh, fetchLogs]);
// Keep view pinned to bottom when the user is already at the bottom.
useEffect(() => {
const el = scrollRef.current;
if (el && atBottomRef.current) {
el.scrollTop = el.scrollHeight;
}
}, [logs]);
function handleScroll() {
const el = scrollRef.current;
if (!el) return;
atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}
return (
<div className="flex h-full flex-col">
<div className="flex shrink-0 flex-wrap items-center gap-2 px-1 pb-3 sm:px-6">
<Select value={level} onValueChange={setLevel}>
<SelectTrigger className="h-8 w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{LEVEL_OPTIONS.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt === 'ALL' ? t('mcp.logsLevelAll') : opt}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={fetchLogs}
disabled={isLoading}
>
<RefreshCw
className={`mr-1.5 size-3.5 ${isLoading ? 'animate-spin' : ''}`}
/>
{t('mcp.logsRefresh')}
</Button>
<div className="flex items-center gap-2">
<Switch
id="mcp-logs-auto-refresh"
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
<Label
htmlFor="mcp-logs-auto-refresh"
className="cursor-pointer text-sm font-normal text-muted-foreground"
>
{t('mcp.logsAutoRefresh')}
</Label>
</div>
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className="min-h-0 flex-1 overflow-auto bg-gray-50 px-3 py-3 font-mono text-xs leading-relaxed dark:bg-gray-900/40 sm:px-6"
>
{logs.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{t('mcp.logsEmpty')}
</div>
) : (
logs.map((entry, idx) => (
<div
key={`${entry.ts}-${idx}`}
className={`whitespace-pre-wrap break-all ${levelClassName(
entry.level,
)}`}
>
{entry.text}
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,649 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertCircle,
Bot,
ChevronDown,
ChevronRight,
Clock,
Cpu,
Hash,
User,
Wrench,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { MessageContentRenderer } from './MessageContentRenderer';
import {
ConversationTurn,
hasRenderableMessageContent,
} from '../utils/conversationTurns';
import { MonitoringMessage } from '../types/monitoring';
interface ConversationTurnListProps {
turns: ConversationTurn[];
expandedTurnId: string | null;
onToggleTurn: (turnId: string) => void;
}
function shortId(id?: string) {
if (!id) return '-';
if (id.length <= 12) return id;
return `${id.slice(0, 8)}...${id.slice(-4)}`;
}
function formatDuration(ms: number) {
if (!ms) return '0ms';
if (ms < 1000) return `${ms}ms`;
return `${(ms / 1000).toFixed(2)}s`;
}
function truncateDetail(value?: string) {
if (!value) return '';
return value.length > 1200 ? `${value.slice(0, 1200)}...` : value;
}
function roleLabel(message: MonitoringMessage | undefined) {
const role = message?.role?.toLowerCase();
if (role === 'assistant') return 'assistant';
if (role === 'user') return 'user';
return 'message';
}
function statusClass(level: ConversationTurn['level']) {
if (level === 'error') {
return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300';
}
if (level === 'warning') {
return 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900 dark:bg-yellow-950/40 dark:text-yellow-300';
}
return 'border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300';
}
function Metric({
icon,
label,
tone = 'default',
}: {
icon: React.ReactNode;
label: string;
tone?: 'default' | 'error';
}) {
return (
<span
className={cn(
'inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs font-medium',
tone === 'error'
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300'
: 'border-border bg-background text-muted-foreground',
)}
>
{icon}
{label}
</span>
);
}
function MetaItem({ label, value }: { label: string; value?: string }) {
return (
<div className="min-w-0 rounded-md bg-background px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="truncate text-sm font-medium text-foreground">
{value || '-'}
</div>
</div>
);
}
function MessageLane({
label,
icon,
content,
empty,
maxLines,
}: {
label: string;
icon: React.ReactNode;
content?: string;
empty: string;
maxLines: number;
}) {
return (
<div className="grid grid-cols-[5.25rem_minmax(0,1fr)] items-start gap-3 text-sm sm:grid-cols-[6rem_minmax(0,1fr)]">
<div className="flex h-7 items-center gap-1.5 text-xs font-medium text-muted-foreground">
{icon}
<span>{label}</span>
</div>
<div className="min-w-0 rounded-md bg-muted/45 px-3 py-2 text-foreground">
{content && hasRenderableMessageContent(content) ? (
<MessageContentRenderer content={content} maxLines={maxLines} />
) : (
<span className="italic text-muted-foreground">{empty}</span>
)}
</div>
</div>
);
}
function ExpandedMessage({
message,
label,
}: {
message: MonitoringMessage;
label: string;
}) {
return (
<div className="border-t border-border/70 py-3 first:border-t-0 first:pt-0 last:pb-0">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="rounded-md bg-muted px-2 py-1 font-medium text-foreground">
{label}
</span>
<span>{message.timestamp.toLocaleString()}</span>
<span className="font-mono">ID: {shortId(message.id)}</span>
</div>
<div className="text-sm leading-6 text-foreground">
<MessageContentRenderer content={message.messageContent} maxLines={4} />
</div>
</div>
);
}
export function ConversationTurnList({
turns,
expandedTurnId,
onToggleTurn,
}: ConversationTurnListProps) {
const { t } = useTranslation();
const [expandedToolCallIds, setExpandedToolCallIds] = React.useState<
Record<string, boolean>
>({});
const toggleToolCallDetails = (toolCallKey: string) => {
setExpandedToolCallIds((previous) => ({
...previous,
[toolCallKey]: !previous[toolCallKey],
}));
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span className="font-medium text-foreground">
{t('monitoring.messageList.turns', {
defaultValue: '{{count}} 轮对话',
count: turns.length,
})}
</span>
</div>
{turns.map((turn) => {
const expanded = expandedTurnId === turn.id;
const firstAssistant = turn.assistantMessages[0];
const assistantOverflow = Math.max(
turn.assistantMessages.length - 1,
0,
);
return (
<div
key={turn.id}
className={cn(
'overflow-hidden rounded-xl border bg-card transition-colors',
turn.level === 'error' && 'border-red-200 dark:border-red-900',
)}
>
<div
role="button"
tabIndex={0}
className="cursor-pointer p-3 outline-none transition-colors hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring sm:p-5"
onClick={() => onToggleTurn(turn.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleTurn(turn.id);
}
}}
>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0 flex-1">
<div className="mb-2 flex min-w-0 items-center gap-2">
{expanded ? (
<ChevronDown className="h-5 w-5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-5 w-5 shrink-0 text-muted-foreground" />
)}
<span className="truncate font-mono text-xs text-muted-foreground">
Turn: {shortId(turn.id)}
</span>
</div>
<div className="mb-3 flex min-w-0 flex-wrap items-center gap-2">
<span className="truncate text-sm font-medium text-foreground">
{turn.botName}
</span>
<span className="text-muted-foreground"></span>
<span className="truncate text-sm text-muted-foreground">
{turn.pipelineName}
</span>
{turn.runnerName && (
<>
<span className="text-muted-foreground"></span>
<span className="truncate text-sm text-muted-foreground">
{turn.runnerName}
</span>
</>
)}
</div>
<div className="space-y-2">
<MessageLane
label={t('monitoring.messageList.userMessage', {
defaultValue: '用户',
})}
icon={<User className="h-3.5 w-3.5" />}
content={turn.userMessage?.messageContent}
empty={t('monitoring.messageList.noUserMessage', {
defaultValue: '未记录用户输入',
})}
maxLines={2}
/>
<MessageLane
label={
assistantOverflow > 0
? t('monitoring.messageList.assistantMessageCount', {
defaultValue: '助手 +{{count}}',
count: assistantOverflow,
})
: t('monitoring.messageList.assistantMessage', {
defaultValue: '助手',
})
}
icon={<Bot className="h-3.5 w-3.5" />}
content={firstAssistant?.messageContent}
empty={t('monitoring.messageList.noAssistantMessage', {
defaultValue: '未记录助手回复',
})}
maxLines={2}
/>
</div>
</div>
<div className="flex shrink-0 flex-col gap-2 lg:items-end">
<div className="text-xs text-muted-foreground">
{turn.lastActivityAt.toLocaleString()}
</div>
<div
className={cn(
'inline-flex h-7 items-center rounded-md border px-2 text-xs font-medium',
statusClass(turn.level),
)}
>
{turn.level}
</div>
<div className="flex flex-wrap gap-2 lg:justify-end">
<Metric
icon={<Cpu className="h-3.5 w-3.5" />}
label={`${turn.llmCalls.length} LLM`}
/>
{turn.toolCalls.length > 0 && (
<Metric
icon={<Wrench className="h-3.5 w-3.5" />}
label={`${turn.toolCalls.length} tools`}
/>
)}
<Metric
icon={<Hash className="h-3.5 w-3.5" />}
label={`${turn.totalTokens.toLocaleString()} tokens`}
/>
<Metric
icon={<Clock className="h-3.5 w-3.5" />}
label={formatDuration(turn.totalDuration)}
/>
{turn.errors.length > 0 && (
<Metric
icon={<AlertCircle className="h-3.5 w-3.5" />}
label={`${turn.errors.length} errors`}
tone="error"
/>
)}
</div>
</div>
</div>
</div>
{expanded && (
<div className="border-t bg-muted/40 p-3 sm:p-5">
<div className="space-y-5 border-l-2 border-border pl-4 sm:pl-6">
<div className="grid grid-cols-2 gap-2 lg:grid-cols-5">
<MetaItem
label={t('monitoring.messageList.platform', {
defaultValue: '平台',
})}
value={turn.platform}
/>
<MetaItem
label={t('monitoring.messageList.user', {
defaultValue: '用户',
})}
value={turn.userName || turn.userId}
/>
<MetaItem
label={t('monitoring.messageList.runner', {
defaultValue: '执行器',
})}
value={turn.runnerName}
/>
<MetaItem
label={t('monitoring.sessions.sessionId', {
defaultValue: '会话 ID',
})}
value={turn.sessionId}
/>
<MetaItem
label={t('monitoring.messageList.messageCount', {
defaultValue: '消息数',
})}
value={String(turn.messages.length)}
/>
</div>
<section>
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Bot className="h-4 w-4" />
{t('monitoring.messageList.conversationTrace', {
defaultValue: '消息链路',
})}
</h4>
<div className="rounded-lg bg-background px-3 py-3">
{turn.messages.map((message) => (
<ExpandedMessage
key={message.id}
message={message}
label={t(
`monitoring.messageList.roles.${roleLabel(message)}`,
{
defaultValue:
roleLabel(message) === 'assistant'
? '助手'
: roleLabel(message) === 'user'
? '用户'
: '消息',
},
)}
/>
))}
</div>
</section>
<section>
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Cpu className="h-4 w-4" />
{t('monitoring.llmCalls.title', {
defaultValue: 'LLM 调用',
})}{' '}
({turn.llmCalls.length})
</h4>
<div className="grid grid-cols-3 gap-2">
<MetaItem
label={t('monitoring.llmCalls.totalTokens', {
defaultValue: '总 Token',
})}
value={turn.totalTokens.toLocaleString()}
/>
<MetaItem
label={t('monitoring.llmCalls.inputTokens', {
defaultValue: '输入 Token',
})}
value={turn.inputTokens.toLocaleString()}
/>
<MetaItem
label={t('monitoring.llmCalls.duration', {
defaultValue: '耗时',
})}
value={formatDuration(turn.totalDuration)}
/>
</div>
<div className="mt-3 rounded-lg bg-background px-3 py-3">
{turn.llmCalls.length > 0 ? (
turn.llmCalls.map((call, index) => (
<div
key={call.id}
className="border-t border-border/70 py-3 first:border-t-0 first:pt-0 last:pb-0"
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<span className="text-sm font-medium text-foreground">
#{index + 1} {call.modelName}
</span>
<span
className={cn(
'rounded-md px-2 py-1 text-xs font-medium',
call.status === 'success'
? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300',
)}
>
{call.status}
</span>
</div>
<span className="text-xs text-muted-foreground">
{formatDuration(call.duration)}
</span>
</div>
<div className="flex flex-wrap gap-x-8 gap-y-1 text-xs text-muted-foreground">
<span>In: {call.tokens.input}</span>
<span>Out: {call.tokens.output}</span>
<span>Total: {call.tokens.total}</span>
<span className="font-mono">
ID: {shortId(call.id)}
</span>
</div>
{call.errorMessage && (
<div className="mt-2 whitespace-pre-wrap break-words text-xs text-red-600 dark:text-red-400">
{call.errorMessage}
</div>
)}
</div>
))
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
{t('monitoring.messageList.noLlmCalls', {
defaultValue: '未记录模型调用',
})}
</div>
)}
</div>
</section>
<section>
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
<Wrench className="h-4 w-4" />
{t('monitoring.toolCalls.title', {
defaultValue: '工具调用',
})}{' '}
({turn.toolCalls.length})
</h4>
<div className="grid grid-cols-2 gap-2 lg:grid-cols-3">
<MetaItem
label={t('monitoring.toolCalls.totalCalls', {
defaultValue: '调用次数',
})}
value={String(turn.toolCalls.length)}
/>
<MetaItem
label={t('monitoring.toolCalls.duration', {
defaultValue: '工具耗时',
})}
value={formatDuration(turn.totalToolDuration)}
/>
<MetaItem
label={t('monitoring.toolCalls.errorCalls', {
defaultValue: '失败次数',
})}
value={String(
turn.toolCalls.filter(
(call) => call.status === 'error',
).length,
)}
/>
</div>
<div className="mt-3 rounded-lg bg-background px-3 py-3">
{turn.toolCalls.length > 0 ? (
turn.toolCalls.map((call, index) => {
const toolCallKey = `${turn.id}:${call.id}`;
const hasToolDetails = Boolean(
call.arguments || call.result || call.errorMessage,
);
const expandedToolCall = Boolean(
expandedToolCallIds[toolCallKey],
);
const detailsId = `monitoring-tool-call-details-${call.id}`;
return (
<div
key={call.id}
className="border-t border-border/70 py-2 first:border-t-0 first:pt-0 last:pb-0"
>
<button
type="button"
className={cn(
'flex w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left outline-none transition-colors',
hasToolDetails &&
'cursor-pointer hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
)}
aria-expanded={
hasToolDetails ? expandedToolCall : undefined
}
aria-controls={
hasToolDetails ? detailsId : undefined
}
aria-disabled={!hasToolDetails}
onClick={() =>
hasToolDetails &&
toggleToolCallDetails(toolCallKey)
}
>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{hasToolDetails &&
(expandedToolCall ? (
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
))}
<span className="min-w-0 truncate text-sm font-medium text-foreground">
#{index + 1} {call.toolName}
</span>
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
{call.toolSource}
</span>
<span
className={cn(
'rounded-md px-2 py-1 text-xs font-medium',
call.status === 'success'
? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300'
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300',
)}
>
{call.status}
</span>
<span className="font-mono text-xs text-muted-foreground">
ID: {shortId(call.id)}
</span>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{formatDuration(call.duration)}
</span>
</button>
{hasToolDetails && expandedToolCall && (
<div
id={detailsId}
className="mt-1 grid gap-2 px-2 pb-2 text-xs lg:grid-cols-2"
>
{call.arguments && (
<div className="min-w-0 rounded-md bg-muted/50 p-2">
<div className="mb-1 font-medium text-foreground">
{t('monitoring.toolCalls.arguments', {
defaultValue: '参数',
})}
</div>
<pre className="whitespace-pre-wrap break-words font-mono text-muted-foreground">
{truncateDetail(call.arguments)}
</pre>
</div>
)}
{call.result && (
<div className="min-w-0 rounded-md bg-muted/50 p-2">
<div className="mb-1 font-medium text-foreground">
{t('monitoring.toolCalls.result', {
defaultValue: '结果',
})}
</div>
<pre className="whitespace-pre-wrap break-words font-mono text-muted-foreground">
{truncateDetail(call.result)}
</pre>
</div>
)}
{call.errorMessage && (
<div className="min-w-0 whitespace-pre-wrap break-words rounded-md bg-red-50 p-2 text-red-600 dark:bg-red-950/40 dark:text-red-400 lg:col-span-2">
{call.errorMessage}
</div>
)}
</div>
)}
</div>
);
})
) : (
<div className="py-4 text-center text-sm text-muted-foreground">
{t('monitoring.toolCalls.noToolCalls', {
defaultValue: '未记录工具调用',
})}
</div>
)}
</div>
</section>
{turn.errors.length > 0 && (
<section>
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-red-700 dark:text-red-300">
<AlertCircle className="h-4 w-4" />
{t('monitoring.errors.title', {
defaultValue: '错误日志',
})}{' '}
({turn.errors.length})
</h4>
<div className="rounded-lg bg-background px-3 py-3">
{turn.errors.map((error) => (
<div
key={error.id}
className="border-t border-red-200/80 py-3 first:border-t-0 first:pt-0 last:pb-0 dark:border-red-900"
>
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-medium text-red-700 dark:text-red-300">
{error.errorType}
</span>
<span className="text-xs text-muted-foreground">
{error.timestamp.toLocaleString()}
</span>
</div>
<div className="whitespace-pre-wrap break-words text-sm text-red-600 dark:text-red-400">
{error.errorMessage}
</div>
</div>
))}
</div>
</section>
)}
</div>
</div>
)}
</div>
);
})}
</div>
);
}
@@ -106,6 +106,9 @@ export function useMonitoringData(filterState: FilterState) {
const llmCalls = Array.isArray(response?.llmCalls)
? response.llmCalls
: [];
const toolCalls = Array.isArray(response?.toolCalls)
? response.toolCalls
: [];
const embeddingCalls = Array.isArray(response?.embeddingCalls)
? response.embeddingCalls
: [];
@@ -116,6 +119,7 @@ export function useMonitoringData(filterState: FilterState) {
const totalCount = response?.totalCount ?? {
messages: messages.length,
llmCalls: llmCalls.length,
toolCalls: toolCalls.length,
embeddingCalls: embeddingCalls.length,
sessions: sessions.length,
errors: errors.length,
@@ -145,8 +149,10 @@ export function useMonitoringData(filterState: FilterState) {
level: string;
platform?: string;
user_id?: string;
user_name?: string;
runner_name?: string;
variables?: string;
role?: string;
}) => ({
id: msg.id,
timestamp: parseUTCTimestamp(msg.timestamp),
@@ -160,8 +166,10 @@ export function useMonitoringData(filterState: FilterState) {
level: msg.level as 'info' | 'warning' | 'error' | 'debug',
platform: msg.platform,
userId: msg.user_id,
userName: msg.user_name,
runnerName: msg.runner_name,
variables: msg.variables,
role: msg.role,
}),
),
llmCalls: llmCalls.map(
@@ -179,6 +187,7 @@ export function useMonitoringData(filterState: FilterState) {
bot_name: string;
pipeline_id: string;
pipeline_name: string;
session_id?: string;
error_message?: string;
message_id?: string;
}) => ({
@@ -197,10 +206,46 @@ export function useMonitoringData(filterState: FilterState) {
botName: call.bot_name,
pipelineId: call.pipeline_id,
pipelineName: call.pipeline_name,
sessionId: call.session_id,
errorMessage: call.error_message,
messageId: call.message_id,
}),
),
toolCalls: toolCalls.map(
(call: {
id: string;
timestamp: string;
tool_name: string;
tool_source: string;
duration: number;
status: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
session_id?: string;
message_id?: string;
arguments?: string;
result?: string;
error_message?: string;
}) => ({
id: call.id,
timestamp: parseUTCTimestamp(call.timestamp),
toolName: call.tool_name,
toolSource: call.tool_source,
duration: call.duration,
status: call.status as 'success' | 'error',
botId: call.bot_id,
botName: call.bot_name,
pipelineId: call.pipeline_id,
pipelineName: call.pipeline_name,
sessionId: call.session_id,
messageId: call.message_id,
arguments: call.arguments,
result: call.result,
errorMessage: call.error_message,
}),
),
embeddingCalls: embeddingCalls.map(
(call: {
id: string;
@@ -294,6 +339,7 @@ export function useMonitoringData(filterState: FilterState) {
totalCount: {
messages: totalCount.messages,
llmCalls: totalCount.llmCalls,
toolCalls: totalCount.toolCalls ?? toolCalls.length,
embeddingCalls: totalCount.embeddingCalls || 0,
sessions: totalCount.sessions,
errors: totalCount.errors,
@@ -317,6 +363,7 @@ export function useMonitoringData(filterState: FilterState) {
botName: call.botName,
pipelineId: call.pipelineId,
pipelineName: call.pipelineName,
sessionId: call.sessionId,
}),
);
+33 -264
View File
@@ -18,60 +18,12 @@ import { ExportDropdown } from './components/ExportDropdown';
import { useMonitoringFilters } from './hooks/useMonitoringFilters';
import { useMonitoringData } from './hooks/useMonitoringData';
import { useFeedbackData } from './hooks/useFeedbackData';
import { MessageDetailsCard } from './components/MessageDetailsCard';
import { MessageContentRenderer } from './components/MessageContentRenderer';
import { ConversationTurnList } from './components/ConversationTurnList';
import { FeedbackStatsCards } from './components/FeedbackCard';
import { FeedbackList } from './components/FeedbackList';
import { MessageDetails } from './types/monitoring';
import { httpClient } from '@/app/infra/http/HttpClient';
import { buildConversationTurns } from './utils/conversationTurns';
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
interface RawMessageData {
id: string;
timestamp: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
message_content: string;
session_id: string;
status: string;
level: string;
platform: string;
user_id: string;
runner_name: string;
variables: Record<string, unknown>;
}
interface RawLLMCallData {
id: string;
timestamp: string;
model_name: string;
status: string;
duration: number;
error_message: string | null;
input_tokens: number;
output_tokens: number;
total_tokens: number;
}
interface RawLLMStatsData {
total_calls: number;
total_input_tokens: number;
total_output_tokens: number;
total_tokens: number;
total_duration_ms: number;
average_duration_ms: number;
}
interface RawErrorData {
id: string;
timestamp: string;
error_type: string;
error_message: string;
stack_trace: string | null;
}
function MonitoringPageContent() {
const { t } = useTranslation();
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
@@ -146,115 +98,37 @@ function MonitoringPageContent() {
setFeedbackRefreshKey((k) => k + 1);
}, [refetch]);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
const [messageDetails, setMessageDetails] = useState<
Record<string, MessageDetails>
>({});
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(
{},
const conversationTurns = useMemo(
() =>
buildConversationTurns(
data?.messages || [],
data?.llmCalls || [],
data?.errors || [],
data?.toolCalls || [],
),
[data?.messages, data?.llmCalls, data?.errors, data?.toolCalls],
);
// State for expanded errors
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
// State for controlled tabs
const [activeTab, setActiveTab] = useState<string>('messages');
// Function to jump to a message record
const jumpToMessage = async (messageId: string) => {
const jumpToMessage = (messageId: string) => {
setActiveTab('messages');
// Small delay to ensure tab switch completes
setTimeout(() => {
toggleMessageExpand(messageId);
const turn = conversationTurns.find((item) =>
item.messages.some((message) => message.id === messageId),
);
setExpandedTurnId(turn?.id ?? 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 toggleTurnExpand = (turnId: string) => {
setExpandedTurnId((current) => (current === turnId ? null : turnId));
};
const toggleErrorExpand = (errorId: string) => {
@@ -342,127 +216,22 @@ function MonitoringPageContent() {
</div>
)}
{!loading &&
data &&
data.messages &&
data.messages.length > 0 && (
<div className="space-y-4">
{data.messages
.filter((msg) => {
// Filter out messages with empty content
const content = msg.messageContent?.trim();
return (
content && content !== '[]' && content !== '""'
);
})
.map((msg) => (
<div
key={msg.id}
className="border rounded-xl overflow-hidden transition-all duration-200"
>
{/* Message Header - Always Visible */}
<div
className="p-3 cursor-pointer hover:bg-accent transition-colors sm:p-5"
onClick={() => toggleMessageExpand(msg.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
{/* Expand Icon */}
<div className="mr-3 mt-0.5">
{expandedMessageId === msg.id ? (
<ChevronDown className="w-5 h-5 text-muted-foreground" />
) : (
<ChevronRight className="w-5 h-5 text-muted-foreground" />
)}
</div>
{!loading && data && conversationTurns.length > 0 && (
<ConversationTurnList
turns={conversationTurns}
expandedTurnId={expandedTurnId}
onToggleTurn={toggleTurnExpand}
/>
)}
{/* Message Info */}
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-muted-foreground font-mono">
ID: {msg.id}
</span>
</div>
<div className="flex items-center gap-2 mb-2">
<span className="font-medium text-sm text-foreground">
{msg.botName}
</span>
<span className="text-muted-foreground">
</span>
<span className="text-sm text-muted-foreground">
{msg.pipelineName}
</span>
{msg.runnerName && (
<>
<span className="text-muted-foreground">
</span>
<span className="text-sm text-muted-foreground">
{msg.runnerName}
</span>
</>
)}
</div>
<div className="text-base text-foreground">
<MessageContentRenderer
content={msg.messageContent}
maxLines={3}
/>
</div>
</div>
</div>
{/* Status and Timestamp */}
<div className="flex flex-col items-end gap-2 ml-4">
<span className="text-xs text-muted-foreground whitespace-nowrap">
{msg.timestamp.toLocaleString()}
</span>
<span
className={`text-xs px-2 py-1 rounded ${
msg.level === 'error'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: msg.level === 'warning'
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
}`}
>
{msg.level}
</span>
</div>
</div>
</div>
{/* Expanded Details */}
{expandedMessageId === msg.id && (
<div className="border-t p-4 bg-muted">
{loadingDetails[msg.id] && (
<div className="py-4 flex justify-center">
<LoadingSpinner size="sm" text="" />
</div>
)}
{!loadingDetails[msg.id] &&
messageDetails[msg.id] && (
<MessageDetailsCard
details={messageDetails[msg.id]}
/>
)}
</div>
)}
</div>
))}
{!loading && (!data || conversationTurns.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<MessageSquare className="h-[3rem] w-[3rem]" />
<div className="text-sm">
{t('monitoring.messageList.noMessages')}
</div>
)}
{!loading &&
(!data || !data.messages || data.messages.length === 0) && (
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
<MessageSquare className="h-[3rem] w-[3rem]" />
<div className="text-sm">
{t('monitoring.messageList.noMessages')}
</div>
</div>
)}
</div>
)}
</div>
</TabsContent>
@@ -11,8 +11,10 @@ export interface MonitoringMessage {
level: 'info' | 'warning' | 'error' | 'debug';
platform?: string;
userId?: string;
userName?: string;
runnerName?: string;
variables?: string;
role?: 'user' | 'assistant' | string;
}
export interface LLMCall {
@@ -31,10 +33,29 @@ export interface LLMCall {
botName: string;
pipelineId: string;
pipelineName: string;
sessionId?: string;
errorMessage?: string;
messageId?: string;
}
export interface ToolCall {
id: string;
timestamp: Date;
toolName: string;
toolSource: 'native' | 'plugin' | 'mcp' | 'skill' | string;
duration: number;
status: 'success' | 'error';
botId: string;
botName: string;
pipelineId: string;
pipelineName: string;
sessionId?: string;
messageId?: string;
arguments?: string;
result?: string;
errorMessage?: string;
}
export interface EmbeddingCall {
id: string;
timestamp: Date;
@@ -199,6 +220,7 @@ export interface MonitoringData {
overview: OverviewMetrics;
messages: MonitoringMessage[];
llmCalls: LLMCall[];
toolCalls: ToolCall[];
embeddingCalls: EmbeddingCall[];
modelCalls: ModelCall[];
sessions: SessionInfo[];
@@ -208,6 +230,7 @@ export interface MonitoringData {
totalCount: {
messages: number;
llmCalls: number;
toolCalls?: number;
embeddingCalls: number;
sessions: number;
errors: number;
@@ -0,0 +1,294 @@
import {
ErrorLog,
LLMCall,
MonitoringMessage,
ToolCall,
} from '../types/monitoring';
type MessageRole = 'user' | 'assistant' | 'unknown';
export interface ConversationTurn {
id: string;
sessionId: string;
startedAt: Date;
lastActivityAt: Date;
botId: string;
botName: string;
pipelineId: string;
pipelineName: string;
runnerName?: string;
platform?: string;
userId?: string;
userName?: string;
userMessage?: MonitoringMessage;
assistantMessages: MonitoringMessage[];
messages: MonitoringMessage[];
llmCalls: LLMCall[];
toolCalls: ToolCall[];
errors: ErrorLog[];
status: 'success' | 'error' | 'pending';
level: 'info' | 'warning' | 'error' | 'debug';
inputTokens: number;
outputTokens: number;
totalTokens: number;
totalDuration: number;
totalToolDuration: number;
}
function normalizeRole(
message: MonitoringMessage,
llmMessageIds: Set<string>,
): MessageRole {
const role = message.role?.toLowerCase();
if (role === 'user' || role === 'assistant') {
return role;
}
if (llmMessageIds.has(message.id)) {
return 'user';
}
return 'unknown';
}
export function hasRenderableMessageContent(content?: string): boolean {
const trimmed = content?.trim();
if (!trimmed || trimmed === '[]' || trimmed === '""') {
return false;
}
try {
const parsed = JSON.parse(trimmed);
if (typeof parsed === 'string') {
return parsed.trim().length > 0;
}
if (Array.isArray(parsed)) {
return parsed.some(
(component) =>
typeof component !== 'object' ||
component === null ||
component.type !== 'Source',
);
}
} catch {
return true;
}
return true;
}
function createTurn(message: MonitoringMessage): ConversationTurn {
return {
id: message.id,
sessionId: message.sessionId,
startedAt: message.timestamp,
lastActivityAt: message.timestamp,
botId: message.botId,
botName: message.botName,
pipelineId: message.pipelineId,
pipelineName: message.pipelineName,
runnerName: message.runnerName,
platform: message.platform,
userId: message.userId,
userName: message.userName,
assistantMessages: [],
messages: [],
llmCalls: [],
toolCalls: [],
errors: [],
status: message.status,
level: message.level,
inputTokens: 0,
outputTokens: 0,
totalTokens: 0,
totalDuration: 0,
totalToolDuration: 0,
};
}
function updateTurnActivity(turn: ConversationTurn, timestamp: Date) {
if (timestamp.getTime() > turn.lastActivityAt.getTime()) {
turn.lastActivityAt = timestamp;
}
}
function addMessageToTurn(
turn: ConversationTurn,
message: MonitoringMessage,
role: MessageRole,
) {
turn.messages.push(message);
updateTurnActivity(turn, message.timestamp);
if (message.level === 'error') {
turn.level = 'error';
} else if (message.level === 'warning' && turn.level !== 'error') {
turn.level = 'warning';
}
if (message.status === 'error') {
turn.status = 'error';
} else if (message.status === 'pending' && turn.status !== 'error') {
turn.status = 'pending';
}
if (role === 'assistant') {
turn.assistantMessages.push(message);
return;
}
if (!turn.userMessage) {
turn.userMessage = message;
turn.userId = message.userId ?? turn.userId;
turn.userName = message.userName ?? turn.userName;
return;
}
turn.assistantMessages.push(message);
}
function findTurnBySessionTime(
sessionTurns: Map<string, ConversationTurn[]>,
sessionId: string | undefined,
timestamp: Date,
): ConversationTurn | undefined {
if (!sessionId) {
return undefined;
}
const turns = sessionTurns.get(sessionId);
if (!turns?.length) {
return undefined;
}
let nearest = turns[0];
const targetTime = timestamp.getTime();
for (const turn of turns) {
if (turn.startedAt.getTime() <= targetTime) {
nearest = turn;
} else {
break;
}
}
return nearest;
}
export function buildConversationTurns(
messages: MonitoringMessage[],
llmCalls: LLMCall[],
errors: ErrorLog[],
toolCalls: ToolCall[] = [],
): ConversationTurn[] {
const activityMessageIds = new Set([
...llmCalls
.map((call) => call.messageId)
.filter((messageId): messageId is string => Boolean(messageId)),
...toolCalls
.map((call) => call.messageId)
.filter((messageId): messageId is string => Boolean(messageId)),
]);
const visibleMessages = messages
.filter((message) => hasRenderableMessageContent(message.messageContent))
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
const sessionTurns = new Map<string, ConversationTurn[]>();
const lastTurnBySession = new Map<string, ConversationTurn>();
const messageIdToTurn = new Map<string, ConversationTurn>();
for (const message of visibleMessages) {
const role = normalizeRole(message, activityMessageIds);
const previousTurn = lastTurnBySession.get(message.sessionId);
const shouldStartTurn = role === 'user' || !previousTurn;
const turn = shouldStartTurn ? createTurn(message) : previousTurn;
if (shouldStartTurn) {
const turns = sessionTurns.get(message.sessionId) ?? [];
turns.push(turn);
sessionTurns.set(message.sessionId, turns);
lastTurnBySession.set(message.sessionId, turn);
}
addMessageToTurn(turn, message, role);
messageIdToTurn.set(message.id, turn);
}
const allTurns = Array.from(sessionTurns.values()).flat();
for (const call of llmCalls) {
const turn =
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
if (!turn) {
continue;
}
turn.llmCalls.push(call);
turn.inputTokens += call.tokens.input;
turn.outputTokens += call.tokens.output;
turn.totalTokens += call.tokens.total;
turn.totalDuration += call.duration;
updateTurnActivity(turn, call.timestamp);
if (call.status === 'error') {
turn.status = 'error';
turn.level = 'error';
}
}
for (const call of toolCalls) {
const turn =
(call.messageId ? messageIdToTurn.get(call.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, call.sessionId, call.timestamp);
if (!turn) {
continue;
}
turn.toolCalls.push(call);
turn.totalToolDuration += call.duration;
updateTurnActivity(turn, call.timestamp);
if (call.status === 'error') {
turn.status = 'error';
turn.level = 'error';
}
}
for (const error of errors) {
const turn =
(error.messageId ? messageIdToTurn.get(error.messageId) : undefined) ??
findTurnBySessionTime(sessionTurns, error.sessionId, error.timestamp);
if (!turn) {
continue;
}
turn.errors.push(error);
turn.status = 'error';
turn.level = 'error';
updateTurnActivity(turn, error.timestamp);
}
for (const turn of allTurns) {
turn.messages.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
turn.assistantMessages.sort(
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
);
turn.llmCalls.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
turn.toolCalls.sort(
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
);
turn.errors.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
}
return allTurns.sort(
(a, b) => b.lastActivityAt.getTime() - a.lastActivityAt.getTime(),
);
}
@@ -12,63 +12,15 @@ import {
Monitor,
} from 'lucide-react';
import { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData';
import { MessageContentRenderer } from '@/app/home/monitoring/components/MessageContentRenderer';
import { ConversationTurnList } from '@/app/home/monitoring/components/ConversationTurnList';
import { buildConversationTurns } from '@/app/home/monitoring/utils/conversationTurns';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { httpClient } from '@/app/infra/http/HttpClient';
import { MessageDetails } from '@/app/home/monitoring/types/monitoring';
import { parseUTCTimestamp } from '@/app/home/monitoring/utils/dateUtils';
interface PipelineMonitoringTabProps {
pipelineId: string;
onNavigateToMonitoring?: () => void;
}
interface RawMessageData {
id: string;
timestamp: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
message_content: string;
session_id: string;
status: string;
level: string;
platform: string;
user_id: string;
runner_name: string;
variables: Record<string, unknown>;
}
interface RawLLMCallData {
id: string;
timestamp: string;
model_name: string;
status: string;
duration: number;
error_message: string | null;
input_tokens: number;
output_tokens: number;
total_tokens: number;
}
interface RawLLMStatsData {
total_calls: number;
total_input_tokens: number;
total_output_tokens: number;
total_tokens: number;
total_duration_ms: number;
average_duration_ms: number;
}
interface RawErrorData {
id: string;
timestamp: string;
error_type: string;
error_message: string;
stack_trace: string | null;
}
export default function PipelineMonitoringTab({
pipelineId,
onNavigateToMonitoring,
@@ -88,98 +40,24 @@ export default function PipelineMonitoringTab({
const { data, loading, refetch } = useMonitoringData(filterState);
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
null,
);
const [messageDetails, setMessageDetails] = useState<
Record<string, MessageDetails>
>({});
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(
{},
const conversationTurns = useMemo(
() =>
data
? buildConversationTurns(
data.messages,
data.llmCalls,
data.errors,
data.toolCalls,
)
: [],
[data],
);
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<string>('messages');
const toggleMessageExpand = async (messageId: string) => {
if (expandedMessageId === messageId) {
setExpandedMessageId(null);
} else {
setExpandedMessageId(messageId);
if (!messageDetails[messageId]) {
setLoadingDetails((prev) => ({ ...prev, [messageId]: true }));
try {
const result = await httpClient.get<{
message_id: string;
found: boolean;
message: RawMessageData | null;
llm_calls: RawLLMCallData[];
llm_stats: RawLLMStatsData;
errors: RawErrorData[];
}>(`/api/v1/monitoring/messages/${messageId}/details`);
if (result) {
setMessageDetails((prev) => ({
...prev,
[messageId]: {
messageId: result.message_id,
found: result.found,
message: result.message
? {
id: result.message.id,
timestamp: parseUTCTimestamp(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: parseUTCTimestamp(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: parseUTCTimestamp(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 toggleTurnExpand = (turnId: string) => {
setExpandedTurnId((current) => (current === turnId ? null : turnId));
};
const toggleErrorExpand = (errorId: string) => {
@@ -190,12 +68,16 @@ export default function PipelineMonitoringTab({
}
};
const jumpToMessage = async (messageId: string) => {
const jumpToMessage = (messageId: string) => {
setActiveTab('messages');
// Small delay to ensure tab transition completes before expanding
setTimeout(() => {
toggleMessageExpand(messageId);
}, 100);
const turn = conversationTurns.find((item) =>
item.messages.some((message) => message.id === messageId),
);
if (turn) {
setExpandedTurnId(turn.id);
}
};
return (
@@ -295,142 +177,22 @@ export default function PipelineMonitoringTab({
</div>
)}
{!loading && data && data.messages && data.messages.length > 0 && (
<div className="space-y-3">
{data.messages
.filter((msg) => {
const content = msg.messageContent?.trim();
return content && content !== '[]' && content !== '""';
})
.map((msg) => (
<div
key={msg.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden hover:shadow-md transition-all duration-200"
>
<div
className="p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors"
onClick={() => toggleMessageExpand(msg.id)}
>
<div className="flex items-start justify-between">
<div className="flex items-start flex-1">
<div className="mr-2 mt-0.5">
{expandedMessageId === msg.id ? (
<ChevronDown className="w-4 h-4 text-gray-500" />
) : (
<ChevronRight className="w-4 h-4 text-gray-500" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span
className={`text-xs px-2 py-0.5 rounded ${
msg.status === 'success'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: msg.status === 'error'
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
}`}
>
{msg.status}
</span>
<span className="text-xs text-gray-500 dark:text-gray-400">
{msg.botName}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 line-clamp-2">
<MessageContentRenderer
content={msg.messageContent}
/>
</div>
</div>
</div>
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
{msg.timestamp.toLocaleString()}
</span>
</div>
</div>
{expandedMessageId === msg.id && (
<div className="border-t border-gray-200 dark:border-gray-700 p-4 bg-gray-50 dark:bg-gray-900">
{loadingDetails[msg.id] && (
<div className="flex justify-center py-8">
<LoadingSpinner
text={t('monitoring.messageList.loading')}
/>
</div>
)}
{!loadingDetails[msg.id] &&
messageDetails[msg.id] && (
<div className="space-y-4">
{messageDetails[msg.id].errors.length > 0 && (
<div className="bg-red-50 dark:bg-red-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
{t('monitoring.errors.errorMessage')}
</h4>
{messageDetails[msg.id].errors.map(
(error) => (
<div
key={error.id}
className="text-sm space-y-2"
>
<div className="text-red-600 dark:text-red-400">
{error.errorType}:{' '}
{error.errorMessage}
</div>
{error.stackTrace && (
<pre className="text-xs text-gray-600 dark:text-gray-400 overflow-auto max-h-40 bg-white dark:bg-gray-900 p-2 rounded whitespace-pre-wrap break-words">
{error.stackTrace}
</pre>
)}
</div>
),
)}
</div>
)}
{messageDetails[msg.id].llmCalls.length > 0 && (
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
<h4 className="text-sm font-semibold text-blue-700 dark:text-blue-400 mb-2">
{t('monitoring.tabs.modelCalls')} (
{messageDetails[msg.id].llmCalls.length})
</h4>
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
<div>
{t('monitoring.llmCalls.totalTokens')}:{' '}
{
messageDetails[msg.id].llmStats
.totalTokens
}
</div>
<div>
{t('monitoring.llmCalls.duration')}:{' '}
{messageDetails[
msg.id
].llmStats.totalDurationMs.toFixed(0)}
ms
</div>
</div>
</div>
)}
</div>
)}
</div>
)}
</div>
))}
</div>
{!loading && data && conversationTurns.length > 0 && (
<ConversationTurnList
turns={conversationTurns}
expandedTurnId={expandedTurnId}
onToggleTurn={toggleTurnExpand}
/>
)}
{!loading &&
(!data || !data.messages || data.messages.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<MessageCircle className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-base font-medium">
{t('monitoring.messageList.noMessages')}
</p>
</div>
)}
{!loading && (!data || conversationTurns.length === 0) && (
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
<MessageCircle className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
<p className="text-base font-medium">
{t('monitoring.messageList.noMessages')}
</p>
</div>
)}
</TabsContent>
{/* Errors Tab */}
+36
View File
@@ -706,6 +706,21 @@ export class BackendClient extends BaseHttpClient {
);
}
public getMcpServerLogs(
serverName: string,
limit: number = 200,
level?: string,
): Promise<{ logs: PluginLogEntry[] }> {
const params = new URLSearchParams();
params.set('limit', String(limit));
if (level) {
params.set('level', level);
}
return this.get(
`/api/v1/mcp/servers/${encodeURIComponent(serverName)}/logs?${params.toString()}`,
);
}
public getPluginAssetURL(
author: string,
name: string,
@@ -1234,8 +1249,10 @@ export class BackendClient extends BaseHttpClient {
level: string;
platform?: string;
user_id?: string;
user_name?: string;
runner_name?: string;
variables?: string;
role?: string;
}>;
llmCalls: Array<{
id: string;
@@ -1251,9 +1268,27 @@ export class BackendClient extends BaseHttpClient {
bot_name: string;
pipeline_id: string;
pipeline_name: string;
session_id?: string;
error_message?: string;
message_id?: string;
}>;
toolCalls: Array<{
id: string;
timestamp: string;
tool_name: string;
tool_source: string;
duration: number;
status: string;
bot_id: string;
bot_name: string;
pipeline_id: string;
pipeline_name: string;
session_id?: string;
message_id?: string;
arguments?: string;
result?: string;
error_message?: string;
}>;
embeddingCalls: Array<{
id: string;
timestamp: string;
@@ -1298,6 +1333,7 @@ export class BackendClient extends BaseHttpClient {
totalCount: {
messages: number;
llmCalls: number;
toolCalls?: number;
embeddingCalls: number;
sessions: number;
errors: number;
+29
View File
@@ -939,6 +939,12 @@ const enUS = {
tabTools: 'Tools',
tabResources: 'Resources',
tabDocs: 'Docs',
tabLogs: 'Logs',
logsLevelAll: 'All levels',
logsRefresh: 'Refresh',
logsAutoRefresh: 'Auto refresh',
logsEmpty:
'No logs yet. Runtime logs from the MCP server will appear here.',
noReadme: 'No documentation available',
parseResultFailed: 'Failed to parse test result',
noResultReturned: 'Test returned no result',
@@ -1440,6 +1446,20 @@ const enUS = {
level: 'Level',
runner: 'Runner',
viewConversation: 'View Conversation',
turns: '{{count}} conversation turns',
userMessage: 'User',
noUserMessage: 'No user input recorded',
assistantMessage: 'Assistant',
assistantMessageCount: 'Assistant +{{count}}',
noAssistantMessage: 'No assistant reply recorded',
messageCount: 'Messages',
conversationTrace: 'Conversation Trace',
noLlmCalls: 'No model calls recorded',
roles: {
user: 'User',
assistant: 'Assistant',
message: 'Message',
},
},
llmCalls: {
title: 'LLM Calls',
@@ -1454,6 +1474,15 @@ const enUS = {
avgDuration: 'Avg Duration',
calls: 'Calls',
},
toolCalls: {
title: 'Tool Calls',
totalCalls: 'Calls',
duration: 'Tool Duration',
errorCalls: 'Failed Calls',
arguments: 'Arguments',
result: 'Result',
noToolCalls: 'No tool calls recorded',
},
tokens: {
totalTokens: 'Total Tokens',
inputTokens: 'Input Tokens',
+29
View File
@@ -901,6 +901,12 @@ const esES = {
tabTools: 'Herramientas',
tabResources: 'Recursos',
tabDocs: 'Documentación',
tabLogs: 'Registros',
logsLevelAll: 'Todos los niveles',
logsRefresh: 'Actualizar',
logsAutoRefresh: 'Actualización automática',
logsEmpty:
'Aún no hay registros. Los registros de ejecución del servidor MCP aparecerán aquí.',
noReadme: 'No hay documentación disponible',
parseResultFailed: 'Error al analizar el resultado de la prueba',
noResultReturned: 'La prueba no devolvió resultados',
@@ -1422,6 +1428,20 @@ const esES = {
level: 'Nivel',
runner: 'Ejecutor',
viewConversation: 'Ver conversación',
turns: '{{count}} turnos de conversación',
userMessage: 'Usuario',
noUserMessage: 'No se registró entrada del usuario',
assistantMessage: 'Asistente',
assistantMessageCount: 'Asistente +{{count}}',
noAssistantMessage: 'No se registró respuesta del asistente',
messageCount: 'Mensajes',
conversationTrace: 'Flujo de conversación',
noLlmCalls: 'No se registraron llamadas al modelo',
roles: {
user: 'Usuario',
assistant: 'Asistente',
message: 'Mensaje',
},
},
llmCalls: {
title: 'Llamadas LLM',
@@ -1436,6 +1456,15 @@ const esES = {
avgDuration: 'Duración promedio',
calls: 'Llamadas',
},
toolCalls: {
title: 'Llamadas de herramientas',
totalCalls: 'Llamadas',
duration: 'Duración de herramientas',
errorCalls: 'Llamadas fallidas',
arguments: 'Argumentos',
result: 'Resultado',
noToolCalls: 'No se registraron llamadas de herramientas',
},
tokens: {
totalTokens: 'Tokens totales',
inputTokens: 'Tokens de entrada',
+28
View File
@@ -927,6 +927,11 @@ const jaJP = {
tabTools: 'ツール',
tabResources: 'リソース',
tabDocs: 'ドキュメント',
tabLogs: 'ログ',
logsLevelAll: 'すべてのレベル',
logsRefresh: '更新',
logsAutoRefresh: '自動更新',
logsEmpty: 'ログはありません。MCPサーバーの実行ログがここに表示されます。',
noReadme: 'ドキュメントがありません',
parseResultFailed: 'テスト結果の解析に失敗しました',
noResultReturned: 'テスト結果が返されませんでした',
@@ -1429,6 +1434,20 @@ const jaJP = {
level: 'レベル',
runner: 'ランナー',
viewConversation: '会話詳細を表示',
turns: '{{count}} 会話ターン',
userMessage: 'ユーザー',
noUserMessage: 'ユーザー入力は記録されていません',
assistantMessage: 'アシスタント',
assistantMessageCount: 'アシスタント +{{count}}',
noAssistantMessage: 'アシスタントの返信は記録されていません',
messageCount: 'メッセージ数',
conversationTrace: '会話トレース',
noLlmCalls: 'モデル呼び出しは記録されていません',
roles: {
user: 'ユーザー',
assistant: 'アシスタント',
message: 'メッセージ',
},
},
llmCalls: {
title: 'LLM呼び出し',
@@ -1443,6 +1462,15 @@ const jaJP = {
avgDuration: '平均期間',
calls: '呼び出し',
},
toolCalls: {
title: 'ツール呼び出し',
totalCalls: '呼び出し',
duration: 'ツール時間',
errorCalls: '失敗した呼び出し',
arguments: '引数',
result: '結果',
noToolCalls: 'ツール呼び出しは記録されていません',
},
tokens: {
totalTokens: '総トークン数',
inputTokens: '入力トークン',
+29
View File
@@ -896,6 +896,12 @@ const ruRU = {
tabTools: 'Инструменты',
tabResources: 'Ресурсы',
tabDocs: 'Документация',
tabLogs: 'Журнал',
logsLevelAll: 'Все уровни',
logsRefresh: 'Обновить',
logsAutoRefresh: 'Автообновление',
logsEmpty:
'Журналов пока нет. Здесь будут отображаться журналы выполнения MCP-сервера.',
noReadme: 'Документация отсутствует',
parseResultFailed: 'Не удалось разобрать результат теста',
noResultReturned: 'Тест не вернул результат',
@@ -1396,6 +1402,20 @@ const ruRU = {
level: 'Уровень',
runner: 'Обработчик',
viewConversation: 'Просмотр диалога',
turns: '{{count}} диалоговых ходов',
userMessage: 'Пользователь',
noUserMessage: 'Ввод пользователя не записан',
assistantMessage: 'Ассистент',
assistantMessageCount: 'Ассистент +{{count}}',
noAssistantMessage: 'Ответ ассистента не записан',
messageCount: 'Сообщения',
conversationTrace: 'Ход диалога',
noLlmCalls: 'Вызовы модели не записаны',
roles: {
user: 'Пользователь',
assistant: 'Ассистент',
message: 'Сообщение',
},
},
llmCalls: {
title: 'Вызовы LLM',
@@ -1410,6 +1430,15 @@ const ruRU = {
avgDuration: 'Средняя длительность',
calls: 'Вызовы',
},
toolCalls: {
title: 'Вызовы инструментов',
totalCalls: 'Вызовы',
duration: 'Длительность инструментов',
errorCalls: 'Неудачные вызовы',
arguments: 'Аргументы',
result: 'Результат',
noToolCalls: 'Вызовы инструментов не записаны',
},
tokens: {
totalTokens: 'Всего токенов',
inputTokens: 'Входные токены',
+28
View File
@@ -874,6 +874,11 @@ const thTH = {
tabTools: 'เครื่องมือ',
tabResources: 'ทรัพยากร',
tabDocs: 'เอกสาร',
tabLogs: 'บันทึก',
logsLevelAll: 'ทุกระดับ',
logsRefresh: 'รีเฟรช',
logsAutoRefresh: 'รีเฟรชอัตโนมัติ',
logsEmpty: 'ยังไม่มีบันทึก บันทึกการทำงานของ MCP Server จะแสดงที่นี่',
noReadme: 'ไม่มีเอกสาร',
parseResultFailed: 'ไม่สามารถแยกวิเคราะห์ผลการทดสอบได้',
noResultReturned: 'การทดสอบไม่ส่งผลลัพธ์กลับมา',
@@ -1364,6 +1369,20 @@ const thTH = {
level: 'ระดับ',
runner: 'ตัวประมวลผล',
viewConversation: 'ดูการสนทนา',
turns: '{{count}} รอบการสนทนา',
userMessage: 'ผู้ใช้',
noUserMessage: 'ยังไม่มีการบันทึกข้อความจากผู้ใช้',
assistantMessage: 'ผู้ช่วย',
assistantMessageCount: 'ผู้ช่วย +{{count}}',
noAssistantMessage: 'ยังไม่มีการบันทึกคำตอบจากผู้ช่วย',
messageCount: 'จำนวนข้อความ',
conversationTrace: 'ลำดับการสนทนา',
noLlmCalls: 'ยังไม่มีการบันทึกการเรียกโมเดล',
roles: {
user: 'ผู้ใช้',
assistant: 'ผู้ช่วย',
message: 'ข้อความ',
},
},
llmCalls: {
title: 'การเรียก LLM',
@@ -1378,6 +1397,15 @@ const thTH = {
avgDuration: 'ระยะเวลาเฉลี่ย',
calls: 'การเรียก',
},
toolCalls: {
title: 'การเรียกใช้เครื่องมือ',
totalCalls: 'การเรียก',
duration: 'ระยะเวลาเครื่องมือ',
errorCalls: 'การเรียกที่ล้มเหลว',
arguments: 'อาร์กิวเมนต์',
result: 'ผลลัพธ์',
noToolCalls: 'ยังไม่มีการบันทึกการเรียกใช้เครื่องมือ',
},
tokens: {
totalTokens: 'Token ทั้งหมด',
inputTokens: 'Token อินพุต',
+29
View File
@@ -889,6 +889,12 @@ const viVN = {
tabTools: 'Công cụ',
tabResources: 'Tài nguyên',
tabDocs: 'Tài liệu',
tabLogs: 'Nhật ký',
logsLevelAll: 'Tất cả cấp độ',
logsRefresh: 'Làm mới',
logsAutoRefresh: 'Tự động làm mới',
logsEmpty:
'Chưa có nhật ký. Nhật ký chạy của MCP Server sẽ hiển thị ở đây.',
noReadme: 'Không có tài liệu',
parseResultFailed: 'Phân tích kết quả kiểm tra thất bại',
noResultReturned: 'Kiểm tra không trả về kết quả',
@@ -1389,6 +1395,20 @@ const viVN = {
level: 'Mức',
runner: 'Trình chạy',
viewConversation: 'Xem cuộc trò chuyện',
turns: '{{count}} lượt hội thoại',
userMessage: 'Người dùng',
noUserMessage: 'Chưa ghi nhận đầu vào người dùng',
assistantMessage: 'Trợ lý',
assistantMessageCount: 'Trợ lý +{{count}}',
noAssistantMessage: 'Chưa ghi nhận phản hồi của trợ lý',
messageCount: 'Số tin nhắn',
conversationTrace: 'Luồng hội thoại',
noLlmCalls: 'Chưa ghi nhận lệnh gọi mô hình',
roles: {
user: 'Người dùng',
assistant: 'Trợ lý',
message: 'Tin nhắn',
},
},
llmCalls: {
title: 'Cuộc gọi LLM',
@@ -1403,6 +1423,15 @@ const viVN = {
avgDuration: 'Thời lượng trung bình',
calls: 'Cuộc gọi',
},
toolCalls: {
title: 'Lượt gọi công cụ',
totalCalls: 'Lượt gọi',
duration: 'Thời lượng công cụ',
errorCalls: 'Lượt gọi thất bại',
arguments: 'Tham số',
result: 'Kết quả',
noToolCalls: 'Chưa ghi nhận lượt gọi công cụ',
},
tokens: {
totalTokens: 'Tổng số Token',
inputTokens: 'Token đầu vào',
+28
View File
@@ -902,6 +902,11 @@ const zhHans = {
tabTools: '工具',
tabResources: '资源',
tabDocs: '文档',
tabLogs: '日志',
logsLevelAll: '全部级别',
logsRefresh: '刷新',
logsAutoRefresh: '自动刷新',
logsEmpty: '暂无日志。MCP 服务器的运行日志会显示在这里。',
noReadme: '暂无文档',
parseResultFailed: '解析测试结果失败',
noResultReturned: '测试未返回结果',
@@ -1374,6 +1379,20 @@ const zhHans = {
level: '级别',
runner: '执行器',
viewConversation: '显示对话详情',
turns: '{{count}} 轮对话',
userMessage: '用户',
noUserMessage: '未记录用户输入',
assistantMessage: '助手',
assistantMessageCount: '助手 +{{count}}',
noAssistantMessage: '未记录助手回复',
messageCount: '消息数',
conversationTrace: '消息链路',
noLlmCalls: '未记录模型调用',
roles: {
user: '用户',
assistant: '助手',
message: '消息',
},
},
llmCalls: {
title: 'LLM调用',
@@ -1388,6 +1407,15 @@ const zhHans = {
avgDuration: '平均耗时',
calls: '调用次数',
},
toolCalls: {
title: '工具调用',
totalCalls: '调用次数',
duration: '工具耗时',
errorCalls: '失败次数',
arguments: '参数',
result: '结果',
noToolCalls: '未记录工具调用',
},
tokens: {
totalTokens: '总 Token 数',
inputTokens: '输入 Token',
+28
View File
@@ -847,6 +847,11 @@ const zhHant = {
tabTools: '工具',
tabResources: '資源',
tabDocs: '文件',
tabLogs: '日誌',
logsLevelAll: '全部級別',
logsRefresh: '重新整理',
logsAutoRefresh: '自動重新整理',
logsEmpty: '暫無日誌。MCP 服務器的運行日誌會顯示在這裡。',
noReadme: '暫無文件',
parseResultFailed: '解析測試結果失敗',
noResultReturned: '測試未返回結果',
@@ -1318,6 +1323,20 @@ const zhHant = {
level: '級別',
runner: '執行器',
viewConversation: '顯示對話詳情',
turns: '{{count}} 輪對話',
userMessage: '使用者',
noUserMessage: '未記錄使用者輸入',
assistantMessage: '助手',
assistantMessageCount: '助手 +{{count}}',
noAssistantMessage: '未記錄助手回覆',
messageCount: '訊息數',
conversationTrace: '訊息鏈路',
noLlmCalls: '未記錄模型呼叫',
roles: {
user: '使用者',
assistant: '助手',
message: '訊息',
},
},
llmCalls: {
title: 'LLM呼叫',
@@ -1332,6 +1351,15 @@ const zhHant = {
avgDuration: '平均持續時間',
calls: '呼叫次數',
},
toolCalls: {
title: '工具呼叫',
totalCalls: '呼叫次數',
duration: '工具耗時',
errorCalls: '失敗次數',
arguments: '參數',
result: '結果',
noToolCalls: '未記錄工具呼叫',
},
tokens: {
totalTokens: '總 Token 數',
inputTokens: '輸入 Token',
@@ -0,0 +1,179 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const botId = 'bot-tool-timeline';
const sessionId = 'person-tool-timeline-user';
const botName = 'Tool Timeline Bot';
const pipelineId = 'pipeline-tool-timeline';
const pipelineName = 'Tool Timeline Pipeline';
function at(minute: number, second = 0) {
return `2026-07-02T10:${String(minute).padStart(2, '0')}:${String(
second,
).padStart(2, '0')}Z`;
}
function sessionMessage(
id: string,
role: 'user' | 'assistant',
minute: number,
content: string,
) {
return {
id,
timestamp: at(minute),
bot_id: botId,
bot_name: botName,
pipeline_id: pipelineId,
pipeline_name: pipelineName,
message_content: content,
session_id: sessionId,
status: 'success',
level: 'info',
platform: role === 'user' ? 'person' : 'bot',
user_id: 'timeline-user',
user_name: 'Timeline User',
runner_name: role === 'assistant' ? 'local-agent' : null,
variables: '{}',
role,
};
}
function toolCall(
id: string,
minute: number,
toolName: string,
duration: number,
status: 'success' | 'error' = 'success',
) {
return {
id,
timestamp: at(minute, 30),
tool_name: toolName,
tool_source: 'native',
duration,
status,
bot_id: botId,
bot_name: botName,
pipeline_id: pipelineId,
pipeline_name: pipelineName,
session_id: sessionId,
message_id: 'user-message',
arguments: JSON.stringify({ target: toolName }),
result: status === 'success' ? JSON.stringify({ ok: true }) : null,
error_message: status === 'error' ? 'Tool execution failed' : null,
};
}
test.describe('bot session monitor tool timeline', () => {
test('renders tool calls as left-side agent events interleaved with messages', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringSessions: [
{
session_id: sessionId,
bot_id: botId,
bot_name: botName,
pipeline_id: pipelineId,
pipeline_name: pipelineName,
message_count: 3,
start_time: at(0),
last_activity: at(4),
is_active: true,
platform: 'person',
user_id: 'timeline-user',
user_name: 'Timeline User',
},
],
sessionMessages: {
[sessionId]: [
sessionMessage('user-message', 'user', 0, 'Need a timeline check'),
sessionMessage(
'assistant-step-1',
'assistant',
2,
'Agent step 1: inspected repository files',
),
sessionMessage(
'assistant-step-2',
'assistant',
4,
'Agent step 2: test suite finished',
),
],
},
sessionAnalyses: {
[sessionId]: {
session_id: sessionId,
found: true,
tool_calls: [
toolCall('tool-repo-read', 1, 'repo_file_read', 80),
toolCall('tool-test-run', 3, 'run_test_suite', 140),
],
},
},
});
await page.goto(`/home/bots?id=${botId}`);
await page.getByRole('tab', { name: /Sessions/ }).click();
await page.getByRole('button', { name: /Timeline User/ }).click();
await expect(page.getByText('Need a timeline check')).toBeVisible();
await expect(
page.getByText('repo_file_read', { exact: true }),
).toBeVisible();
await expect(
page.getByText('Agent step 1: inspected repository files'),
).toBeVisible();
await expect(
page.getByText('run_test_suite', { exact: true }),
).toBeVisible();
await expect(
page.getByText('Agent step 2: test suite finished'),
).toBeVisible();
await expect(page.getByText('{"target":"repo_file_read"}')).toHaveCount(0);
await expect(page.getByText('{"ok":true}')).toHaveCount(0);
await expect(
page.locator('div.flex.justify-start').filter({
hasText: 'repo_file_read',
}),
).toHaveCount(1);
await expect(
page.locator('div.flex.justify-start').filter({
hasText: 'run_test_suite',
}),
).toHaveCount(1);
await expect(
page.locator('div.flex.justify-end').filter({
hasText: 'repo_file_read',
}),
).toHaveCount(0);
await expect(
page.locator('div.flex.justify-end').filter({
hasText: 'run_test_suite',
}),
).toHaveCount(0);
const text = await page.locator('body').innerText();
expect(text.indexOf('Need a timeline check')).toBeLessThan(
text.indexOf('repo_file_read'),
);
expect(text.indexOf('repo_file_read')).toBeLessThan(
text.indexOf('Agent step 1: inspected repository files'),
);
expect(
text.indexOf('Agent step 1: inspected repository files'),
).toBeLessThan(text.indexOf('run_test_suite'));
expect(text.indexOf('run_test_suite')).toBeLessThan(
text.indexOf('Agent step 2: test suite finished'),
);
await page.getByText('repo_file_read', { exact: true }).click();
await expect(page.getByText('{"target":"repo_file_read"}')).toBeVisible();
await expect(page.getByText('{"ok":true}').first()).toBeVisible();
});
});
+25
View File
@@ -88,6 +88,31 @@ test.describe('frontend CRUD smoke flows', () => {
).toBeVisible();
});
test('opens pipeline AI capabilities with malformed model options', async ({
page,
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.goto('/home/pipelines?id=pipeline-ai');
await expect(page.locator('input[name="basic.name"]')).toBeVisible();
await page.getByRole('button', { name: /^AI$/ }).click();
await expect(page.getByText('Runtime')).toBeVisible();
await expect(
page.locator('[data-slot="card-title"]').filter({
hasText: 'Built-in Agent',
}),
).toBeVisible();
await expect(
page.locator('label').filter({
hasText: 'Model',
}),
).toBeVisible();
await expect(page.getByText('A <Select.Item')).toHaveCount(0);
await expect(page.getByText('500')).toHaveCount(0);
});
test('creates, edits, and deletes a knowledge base', async ({ page }) => {
await installLangBotApiMocks(page, { authenticated: true });
+169 -5
View File
@@ -72,7 +72,11 @@ interface LangBotApiMockState {
counters: Record<string, number>;
knowledgeBases: KnowledgeBaseMock[];
mcpServers: MCPServerMock[];
monitoringData: unknown;
monitoringSessions: unknown[];
pipelines: PipelineMock[];
sessionAnalyses: Record<string, unknown>;
sessionMessages: Record<string, unknown[]>;
skills: SkillMock[];
}
@@ -122,12 +126,14 @@ function emptyMonitoringData() {
},
messages: [],
llmCalls: [],
toolCalls: [],
embeddingCalls: [],
sessions: [],
errors: [],
totalCount: {
messages: 0,
llmCalls: 0,
toolCalls: 0,
embeddingCalls: 0,
sessions: 0,
errors: 0,
@@ -188,6 +194,102 @@ function makePipeline(
};
}
function pipelineMetadata() {
return {
configs: [
{
name: 'ai',
label: {
en_US: 'AI Capabilities',
zh_Hans: 'AI 能力',
},
stages: [
{
name: 'runner',
label: {
en_US: 'Runtime',
zh_Hans: '运行方式',
},
config: [
{
id: 'runner',
name: 'runner',
label: {
en_US: 'Runner',
zh_Hans: '运行器',
},
type: 'select',
required: true,
default: 'local-agent',
options: [
{
name: 'local-agent',
label: {
en_US: 'Built-in Agent',
zh_Hans: '内置 Agent',
},
},
],
},
],
},
{
name: 'local-agent',
label: {
en_US: 'Built-in Agent',
zh_Hans: '内置 Agent',
},
config: [
{
id: 'model',
name: 'model',
label: {
en_US: 'Model',
zh_Hans: '模型',
},
type: 'model-fallback-selector',
required: true,
default: {
primary: 'llm-valid',
fallbacks: [],
},
},
],
},
],
},
],
};
}
function providerModelList() {
return {
models: [
{
uuid: '',
name: 'Broken Empty UUID Model',
provider_uuid: 'provider-empty',
provider: {
uuid: 'provider-empty',
name: 'Broken Provider',
requester: 'mock-provider',
},
},
{
uuid: 'llm-valid',
name: 'Valid Mock Model',
provider_uuid: 'provider-valid',
provider: {
uuid: 'provider-valid',
name: 'Mock Provider',
requester: 'mock-provider',
},
abilities: ['func_call'],
},
],
};
}
function knowledgeEngine() {
return {
plugin_id: 'builtin/minimal-knowledge',
@@ -389,8 +491,20 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
});
}
if (path === '/api/v1/provider/models/llm') {
return fulfillJson(route, providerModelList());
}
if (path === '/api/v1/provider/models/embedding') {
return fulfillJson(route, { models: [] });
}
if (path === '/api/v1/provider/models/rerank') {
return fulfillJson(route, { models: [] });
}
if (path === '/api/v1/pipelines/_/metadata') {
return fulfillJson(route, { configs: [] });
return fulfillJson(route, pipelineMetadata());
}
if (path === '/api/v1/pipelines') {
@@ -689,11 +803,43 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
}
if (path === '/api/v1/monitoring/data') {
return fulfillJson(route, emptyMonitoringData());
return fulfillJson(route, state.monitoringData);
}
if (path === '/api/v1/monitoring/sessions') {
return fulfillJson(route, {
sessions: state.monitoringSessions,
total: state.monitoringSessions.length,
});
}
if (path === '/api/v1/monitoring/messages') {
const sessionId = url.searchParams.get('sessionId') || '';
const messages = state.sessionMessages[sessionId] || [];
return fulfillJson(route, {
messages,
total: messages.length,
});
}
const sessionAnalysisMatch = path.match(
/^\/api\/v1\/monitoring\/sessions\/([^/]+)\/analysis$/,
);
if (sessionAnalysisMatch) {
const sessionId = decodeURIComponent(sessionAnalysisMatch[1]);
return fulfillJson(
route,
state.sessionAnalyses[sessionId] || {
session_id: sessionId,
found: true,
tool_calls: [],
},
);
}
if (path === '/api/v1/monitoring/overview') {
return fulfillJson(route, emptyMonitoringData().overview);
const data = state.monitoringData as { overview?: unknown };
return fulfillJson(route, data.overview || emptyMonitoringData().overview);
}
if (path === '/api/v1/monitoring/token-statistics') {
@@ -798,15 +944,33 @@ async function handleCloudApi(route: Route) {
export async function installLangBotApiMocks(
page: Page,
options: { authenticated?: boolean; storage?: JsonRecord } = {},
options: {
authenticated?: boolean;
monitoringData?: unknown;
monitoringSessions?: unknown[];
sessionAnalyses?: Record<string, unknown>;
sessionMessages?: Record<string, unknown[]>;
storage?: JsonRecord;
} = {},
) {
const { authenticated = false, storage = {} } = options;
const {
authenticated = false,
monitoringData,
monitoringSessions,
sessionAnalyses,
sessionMessages,
storage = {},
} = options;
const state: LangBotApiMockState = {
bots: [],
counters: {},
knowledgeBases: [],
mcpServers: [],
monitoringData: monitoringData || emptyMonitoringData(),
monitoringSessions: monitoringSessions || [],
pipelines: [],
sessionAnalyses: sessionAnalyses || {},
sessionMessages: sessionMessages || {},
skills: [],
};
+453
View File
@@ -0,0 +1,453 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
import { buildConversationTurns } from '../../src/app/home/monitoring/utils/conversationTurns';
import {
ErrorLog,
LLMCall,
MonitoringMessage,
ToolCall,
} from '../../src/app/home/monitoring/types/monitoring';
const bot = {
id: 'bot-monitoring',
name: 'Monitoring Bot',
};
const pipeline = {
id: 'pipeline-monitoring',
name: 'Monitoring Pipeline',
};
function time(minute: number) {
return new Date(`2026-07-02T10:${String(minute).padStart(2, '0')}:00Z`);
}
function message(
id: string,
role: 'user' | 'assistant',
minute: number,
content: string,
sessionId = 'session-agent',
): MonitoringMessage {
return {
id,
timestamp: time(minute),
botId: bot.id,
botName: bot.name,
pipelineId: pipeline.id,
pipelineName: pipeline.name,
messageContent: content,
sessionId,
status: 'success',
level: 'info',
platform: role === 'user' ? 'person' : 'bot',
userId: 'user-1',
userName: 'Playwright User',
runnerName: 'local-agent',
variables: '{}',
role,
};
}
function llmCall(
id: string,
minute: number,
messageId: string | undefined,
input: number,
output: number,
duration: number,
sessionId = 'session-agent',
): LLMCall {
return {
id,
timestamp: time(minute),
modelName: 'gpt-5.5',
tokens: {
input,
output,
total: input + output,
},
duration,
status: 'success',
botId: bot.id,
botName: bot.name,
pipelineId: pipeline.id,
pipelineName: pipeline.name,
sessionId,
messageId,
};
}
function errorLog(id: string, minute: number, messageId: string): ErrorLog {
return {
id,
timestamp: time(minute),
errorType: 'ToolExecutionError',
errorMessage: 'Tool retry failed',
botId: bot.id,
botName: bot.name,
pipelineId: pipeline.id,
pipelineName: pipeline.name,
sessionId: 'session-agent',
messageId,
};
}
function toolCall(
id: string,
minute: number,
messageId: string | undefined,
toolName: string,
duration: number,
sessionId = 'session-agent',
status: 'success' | 'error' = 'success',
): ToolCall {
return {
id,
timestamp: time(minute),
toolName,
toolSource: 'native',
duration,
status,
botId: bot.id,
botName: bot.name,
pipelineId: pipeline.id,
pipelineName: pipeline.name,
sessionId,
messageId,
arguments: JSON.stringify({ query: toolName }),
result: status === 'success' ? JSON.stringify({ ok: true }) : undefined,
errorMessage: status === 'error' ? 'Tool failed' : undefined,
};
}
function rawMessage(message: MonitoringMessage) {
return {
id: message.id,
timestamp: message.timestamp.toISOString(),
bot_id: message.botId,
bot_name: message.botName,
pipeline_id: message.pipelineId,
pipeline_name: message.pipelineName,
message_content: message.messageContent,
session_id: message.sessionId,
status: message.status,
level: message.level,
platform: message.platform,
user_id: message.userId,
user_name: message.userName,
runner_name: message.runnerName,
variables: message.variables,
role: message.role,
};
}
function rawLlmCall(call: LLMCall) {
return {
id: call.id,
timestamp: call.timestamp.toISOString(),
model_name: call.modelName,
input_tokens: call.tokens.input,
output_tokens: call.tokens.output,
total_tokens: call.tokens.total,
duration: call.duration,
cost: call.cost,
status: call.status,
bot_id: call.botId,
bot_name: call.botName,
pipeline_id: call.pipelineId,
pipeline_name: call.pipelineName,
session_id: call.sessionId,
error_message: call.errorMessage,
message_id: call.messageId,
};
}
function rawError(error: ErrorLog) {
return {
id: error.id,
timestamp: error.timestamp.toISOString(),
error_type: error.errorType,
error_message: error.errorMessage,
bot_id: error.botId,
bot_name: error.botName,
pipeline_id: error.pipelineId,
pipeline_name: error.pipelineName,
session_id: error.sessionId,
stack_trace: error.stackTrace,
message_id: error.messageId,
};
}
function rawToolCall(call: ToolCall) {
return {
id: call.id,
timestamp: call.timestamp.toISOString(),
tool_name: call.toolName,
tool_source: call.toolSource,
duration: call.duration,
status: call.status,
bot_id: call.botId,
bot_name: call.botName,
pipeline_id: call.pipelineId,
pipeline_name: call.pipelineName,
session_id: call.sessionId,
message_id: call.messageId,
arguments: call.arguments,
result: call.result,
error_message: call.errorMessage,
};
}
function monitoringScenario() {
const messages = [
message(
'single-user',
'user',
1,
'Standalone question with no reply',
'session-single',
),
message('agent-user-1', 'user', 10, 'Need deployment plan'),
message('agent-assistant-1', 'assistant', 11, 'Agent step 1: inspect repo'),
message('agent-assistant-2', 'assistant', 12, 'Agent step 2: run tests'),
message(
'agent-assistant-3',
'assistant',
13,
'Final answer: deployment plan ready',
),
message('agent-user-2', 'user', 20, 'Continue with rollback plan'),
message('agent-assistant-4', 'assistant', 21, 'Rollback plan ready'),
];
const llmCalls = [
llmCall('agent-call-1', 10, 'agent-user-1', 100, 40, 120),
llmCall('agent-call-2', 11, 'agent-user-1', 200, 60, 220),
llmCall('agent-call-3', 12, 'agent-user-1', 300, 90, 260),
llmCall('agent-call-4', 20, 'agent-user-2', 50, 25, 80),
];
const errors = [errorLog('agent-error-1', 12, 'agent-user-1')];
const toolCalls = [
toolCall('agent-tool-1', 11, 'agent-user-1', 'repo_search', 90),
toolCall('agent-tool-2', 12, 'agent-user-1', 'run_tests', 150),
toolCall('agent-tool-3', 20, 'agent-user-2', 'rollback_lookup', 70),
];
return {
messages,
llmCalls,
toolCalls,
errors,
};
}
function rawMonitoringData() {
const scenario = monitoringScenario();
return {
overview: {
total_messages: scenario.messages.length,
llm_calls: scenario.llmCalls.length,
embedding_calls: 0,
model_calls: scenario.llmCalls.length,
success_rate: 100,
active_sessions: 2,
},
messages: scenario.messages.map(rawMessage),
llmCalls: scenario.llmCalls.map(rawLlmCall),
toolCalls: scenario.toolCalls.map(rawToolCall),
embeddingCalls: [],
sessions: [],
errors: scenario.errors.map(rawError),
totalCount: {
messages: scenario.messages.length,
llmCalls: scenario.llmCalls.length,
toolCalls: scenario.toolCalls.length,
embeddingCalls: 0,
sessions: 0,
errors: scenario.errors.length,
},
};
}
test.describe('monitoring conversation turn grouping', () => {
test('keeps a single user message as one observable turn', () => {
const userOnly = message(
'single-user-only',
'user',
1,
'No answer yet',
'session-user-only',
);
const turns = buildConversationTurns([userOnly], [], []);
expect(turns).toHaveLength(1);
expect(turns[0].id).toBe(userOnly.id);
expect(turns[0].userMessage?.messageContent).toBe('No answer yet');
expect(turns[0].assistantMessages).toHaveLength(0);
expect(turns[0].llmCalls).toHaveLength(0);
expect(turns[0].totalTokens).toBe(0);
});
test('groups multi-step agent execution and multiple replies into one user turn', () => {
const scenario = monitoringScenario();
const turns = buildConversationTurns(
scenario.messages,
scenario.llmCalls,
scenario.errors,
scenario.toolCalls,
);
const agentTurn = turns.find((turn) => turn.id === 'agent-user-1');
expect(agentTurn).toBeTruthy();
expect(agentTurn?.userMessage?.messageContent).toBe('Need deployment plan');
expect(
agentTurn?.assistantMessages.map((item) => item.messageContent),
).toEqual([
'Agent step 1: inspect repo',
'Agent step 2: run tests',
'Final answer: deployment plan ready',
]);
expect(agentTurn?.llmCalls).toHaveLength(3);
expect(agentTurn?.toolCalls).toHaveLength(2);
expect(agentTurn?.errors).toHaveLength(1);
expect(agentTurn?.totalTokens).toBe(790);
expect(agentTurn?.totalDuration).toBe(600);
expect(agentTurn?.totalToolDuration).toBe(240);
});
test('starts a new turn for each later user message in the same session', () => {
const firstUser = message('same-session-user-1', 'user', 1, 'First');
const firstReply = message(
'same-session-reply-1',
'assistant',
2,
'First reply',
);
const secondUser = message('same-session-user-2', 'user', 3, 'Second');
const secondReply = message(
'same-session-reply-2',
'assistant',
4,
'Second reply',
);
const turns = buildConversationTurns(
[firstUser, firstReply, secondUser, secondReply],
[
llmCall('same-session-call-1', 1, firstUser.id, 10, 5, 40),
llmCall('same-session-call-2', 3, secondUser.id, 20, 10, 50),
],
[],
);
expect(turns.map((turn) => turn.id)).toEqual([
'same-session-user-2',
'same-session-user-1',
]);
expect(
turns[0].assistantMessages.map((item) => item.messageContent),
).toEqual(['Second reply']);
expect(
turns[1].assistantMessages.map((item) => item.messageContent),
).toEqual(['First reply']);
});
test('attaches calls without message ids by session time', () => {
const user = message('fallback-user', 'user', 1, 'Use session fallback');
const assistant = message(
'fallback-assistant',
'assistant',
2,
'Fallback reply',
);
const call = llmCall('fallback-call', 2, undefined, 25, 5, 70);
const turns = buildConversationTurns([user, assistant], [call], []);
expect(turns).toHaveLength(1);
expect(turns[0].llmCalls).toHaveLength(1);
expect(turns[0].llmCalls[0].id).toBe(call.id);
expect(turns[0].totalTokens).toBe(30);
});
test('attaches tool calls without message ids by session time', () => {
const user = message('tool-fallback-user', 'user', 1, 'Use tool fallback');
const assistant = message(
'tool-fallback-assistant',
'assistant',
2,
'Tool fallback reply',
);
const call = toolCall(
'tool-fallback-call',
2,
undefined,
'memory_lookup',
45,
);
const turns = buildConversationTurns([user, assistant], [], [], [call]);
expect(turns).toHaveLength(1);
expect(turns[0].toolCalls).toHaveLength(1);
expect(turns[0].toolCalls[0].id).toBe(call.id);
expect(turns[0].totalToolDuration).toBe(45);
});
test('renders user-only, multi-agent, and multi-turn cases in the monitoring page', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: rawMonitoringData(),
});
await page.goto('/home/monitoring');
await expect(page.getByText('3 conversation turns')).toBeVisible();
await expect(
page.getByText('Standalone question with no reply'),
).toBeVisible();
await expect(page.getByText('No assistant reply recorded')).toBeVisible();
await expect(page.getByText('Need deployment plan')).toBeVisible();
await expect(page.getByText('Agent step 1: inspect repo')).toBeVisible();
await expect(page.getByText('Assistant +2')).toBeVisible();
await expect(page.getByText('3 LLM')).toBeVisible();
await expect(page.getByText('2 tools')).toBeVisible();
await expect(page.getByText('790 tokens')).toBeVisible();
await expect(page.getByText('1 errors')).toBeVisible();
await expect(page.getByText('Continue with rollback plan')).toBeVisible();
await expect(page.getByText('Rollback plan ready')).toBeVisible();
const agentTurn = page
.locator('div[role="button"]')
.filter({ hasText: 'Need deployment plan' });
await expect(agentTurn).toHaveCount(1);
await agentTurn.click();
await expect(page.getByText('Conversation Trace')).toBeVisible();
await expect(page.getByText('Agent step 2: run tests')).toBeVisible();
await expect(
page.getByText('Final answer: deployment plan ready'),
).toBeVisible();
await expect(page.getByText('LLM Calls (3)')).toBeVisible();
await expect(page.getByText('#3 gpt-5.5')).toBeVisible();
await expect(page.getByText('In: 300')).toBeVisible();
await expect(page.getByText('Out: 90')).toBeVisible();
await expect(page.getByText('Total: 390')).toBeVisible();
await expect(page.getByText('Tool Calls (2)')).toBeVisible();
await expect(page.getByText('#1 repo_search')).toBeVisible();
await expect(page.getByText('#2 run_tests')).toBeVisible();
await expect(page.getByText('Arguments')).toHaveCount(0);
await expect(page.getByText('Result')).toHaveCount(0);
await page.getByText('#1 repo_search').click();
await expect(page.getByText('Arguments').first()).toBeVisible();
await expect(page.getByText('Result').first()).toBeVisible();
await expect(page.getByText('Tool retry failed')).toBeVisible();
});
});
@@ -0,0 +1,195 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
const bot = {
id: 'bot-pipeline-monitoring',
name: 'Pipeline Bot',
};
const pipeline = {
id: 'pipeline-monitoring',
name: 'Pipeline Under Test',
};
function at(minute: number) {
return `2026-07-02T10:${String(minute).padStart(2, '0')}:00Z`;
}
function message(
id: string,
role: 'user' | 'assistant',
minute: number,
content: string,
sessionId = 'session-pipeline-agent',
) {
return {
id,
timestamp: at(minute),
bot_id: bot.id,
bot_name: bot.name,
pipeline_id: pipeline.id,
pipeline_name: pipeline.name,
message_content: content,
session_id: sessionId,
status: 'success',
level: 'info',
platform: role === 'user' ? 'person' : 'bot',
user_id: 'pipeline-user',
user_name: 'Pipeline User',
runner_name: 'local-agent',
variables: '{}',
role,
};
}
function llmCall(
id: string,
minute: number,
messageId: string,
input: number,
output: number,
duration: number,
) {
return {
id,
timestamp: at(minute),
model_name: 'gpt-5.5',
input_tokens: input,
output_tokens: output,
total_tokens: input + output,
duration,
cost: 0,
status: 'success',
bot_id: bot.id,
bot_name: bot.name,
pipeline_id: pipeline.id,
pipeline_name: pipeline.name,
session_id: 'session-pipeline-agent',
message_id: messageId,
};
}
function toolCall(id: string, minute: number, messageId: string, name: string) {
return {
id,
timestamp: at(minute),
tool_name: name,
tool_source: 'native',
duration: 120,
status: 'success',
bot_id: bot.id,
bot_name: bot.name,
pipeline_id: pipeline.id,
pipeline_name: pipeline.name,
session_id: 'session-pipeline-agent',
message_id: messageId,
arguments: JSON.stringify({ query: name }),
result: JSON.stringify({ ok: true }),
};
}
function monitoringData() {
const messages = [
message(
'single-user',
'user',
1,
'Pipeline single user message without reply',
'session-pipeline-single',
),
message('agent-user', 'user', 10, 'Pipeline needs a deployment plan'),
message(
'agent-assistant-1',
'assistant',
11,
'Pipeline agent step 1: inspect repository',
),
message(
'agent-assistant-2',
'assistant',
12,
'Pipeline agent step 2: run tests',
),
message(
'agent-assistant-3',
'assistant',
13,
'Pipeline final answer: deployment ready',
),
];
const llmCalls = [
llmCall('pipeline-call-1', 10, 'agent-user', 100, 40, 180),
llmCall('pipeline-call-2', 11, 'agent-user', 140, 50, 220),
];
const toolCalls = [
toolCall('pipeline-tool-1', 11, 'agent-user', 'repo_search'),
toolCall('pipeline-tool-2', 12, 'agent-user', 'run_tests'),
];
return {
overview: {
total_messages: messages.length,
llm_calls: llmCalls.length,
embedding_calls: 0,
model_calls: llmCalls.length,
success_rate: 100,
active_sessions: 2,
},
messages,
llmCalls,
toolCalls,
embeddingCalls: [],
sessions: [],
errors: [],
totalCount: {
messages: messages.length,
llmCalls: llmCalls.length,
toolCalls: toolCalls.length,
embeddingCalls: 0,
sessions: 0,
errors: 0,
},
};
}
test.describe('pipeline monitoring conversation turns', () => {
test('uses conversation turns and folded tool calls in the pipeline dashboard', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
monitoringData: monitoringData(),
});
await page.goto(`/home/pipelines?id=${pipeline.id}`);
await page.getByRole('tab', { name: 'Dashboard' }).click();
await expect(page.getByText('2 conversation turns')).toBeVisible();
await expect(
page.getByText('Pipeline single user message without reply'),
).toBeVisible();
await expect(
page.getByText('Pipeline needs a deployment plan'),
).toBeVisible();
await expect(
page.getByText('Pipeline agent step 1: inspect repository'),
).toBeVisible();
await expect(page.getByText('Assistant +2')).toBeVisible();
await expect(page.getByText('2 tools')).toBeVisible();
const agentTurn = page
.locator('div[role="button"]')
.filter({ hasText: 'Pipeline needs a deployment plan' });
await expect(agentTurn).toHaveCount(1);
await agentTurn.click();
await expect(page.getByText('Tool Calls (2)')).toBeVisible();
await expect(page.getByText('#1 repo_search')).toBeVisible();
await expect(page.getByText('#2 run_tests')).toBeVisible();
await expect(page.getByText('Arguments')).toHaveCount(0);
await page.getByText('#1 repo_search').click();
await expect(page.getByText('Arguments')).toBeVisible();
await expect(page.getByText('Result')).toBeVisible();
});
});