mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-31 14:47:13 +00:00
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:
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '入力トークン',
|
||||
|
||||
@@ -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: 'Входные токены',
|
||||
|
||||
@@ -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 อินพุต',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user