mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 17:36:07 +00:00
Improve pipeline monitoring and AI tab resilience
This commit is contained in:
@@ -67,6 +67,16 @@ import SettingsDialog, {
|
|||||||
import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors';
|
import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors';
|
||||||
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types';
|
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types';
|
||||||
|
|
||||||
|
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({
|
export default function DynamicFormItemComponent({
|
||||||
config,
|
config,
|
||||||
field,
|
field,
|
||||||
@@ -104,7 +114,7 @@ export default function DynamicFormItemComponent({
|
|||||||
httpClient
|
httpClient
|
||||||
.getProviderLLMModels()
|
.getProviderLLMModels()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setLlmModels(resp.models);
|
setLlmModels(resp.models.filter(hasUsableUuid));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(t('models.getModelListError') + err.msg);
|
toast.error(t('models.getModelListError') + err.msg);
|
||||||
@@ -115,7 +125,7 @@ export default function DynamicFormItemComponent({
|
|||||||
httpClient
|
httpClient
|
||||||
.getProviderEmbeddingModels()
|
.getProviderEmbeddingModels()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setEmbeddingModels(resp.models);
|
setEmbeddingModels(resp.models.filter(hasUsableUuid));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(t('embedding.getModelListError') + err.msg);
|
toast.error(t('embedding.getModelListError') + err.msg);
|
||||||
@@ -126,7 +136,7 @@ export default function DynamicFormItemComponent({
|
|||||||
httpClient
|
httpClient
|
||||||
.getProviderRerankModels()
|
.getProviderRerankModels()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setRerankModels(resp.models);
|
setRerankModels(resp.models.filter(hasUsableUuid));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error('Failed to load rerank models: ' + err.msg);
|
toast.error('Failed to load rerank models: ' + err.msg);
|
||||||
@@ -230,7 +240,7 @@ export default function DynamicFormItemComponent({
|
|||||||
httpClient
|
httpClient
|
||||||
.getKnowledgeBases()
|
.getKnowledgeBases()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setKnowledgeBases(resp.bases);
|
setKnowledgeBases(resp.bases.filter(hasUsableUuid));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
|
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
|
||||||
@@ -243,7 +253,7 @@ export default function DynamicFormItemComponent({
|
|||||||
httpClient
|
httpClient
|
||||||
.getBots()
|
.getBots()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setBots(resp.bots);
|
setBots(resp.bots.filter(hasUsableUuid));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(t('bots.getBotListError') + err.msg);
|
toast.error(t('bots.getBotListError') + err.msg);
|
||||||
@@ -388,15 +398,17 @@ export default function DynamicFormItemComponent({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{config.options?.map((option) => (
|
{config.options
|
||||||
<SelectItem
|
?.filter(hasUsableOptionName)
|
||||||
key={option.name}
|
.map((option) => (
|
||||||
value={option.name}
|
<SelectItem
|
||||||
description={option.name}
|
key={option.name}
|
||||||
>
|
value={option.name}
|
||||||
{extractI18nObject(option.label)}
|
description={option.name}
|
||||||
</SelectItem>
|
>
|
||||||
))}
|
{extractI18nObject(option.label)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
@@ -1176,7 +1188,8 @@ export default function DynamicFormItemComponent({
|
|||||||
|
|
||||||
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
|
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
|
||||||
// Group KBs by Knowledge Engine name
|
// Group KBs by Knowledge Engine name
|
||||||
const kbsByEngine = knowledgeBases.reduce(
|
const validKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
|
||||||
|
const kbsByEngine = validKnowledgeBases.reduce(
|
||||||
(acc, kb) => {
|
(acc, kb) => {
|
||||||
const engineName = kb.knowledge_engine?.name
|
const engineName = kb.knowledge_engine?.name
|
||||||
? extractI18nObject(kb.knowledge_engine.name)
|
? extractI18nObject(kb.knowledge_engine.name)
|
||||||
@@ -1187,7 +1200,7 @@ export default function DynamicFormItemComponent({
|
|||||||
acc[engineName].push(kb);
|
acc[engineName].push(kb);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, typeof knowledgeBases>,
|
{} as Record<string, typeof validKnowledgeBases>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1195,7 +1208,7 @@ export default function DynamicFormItemComponent({
|
|||||||
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||||
{field.value && field.value !== '__none__' ? (
|
{field.value && field.value !== '__none__' ? (
|
||||||
(() => {
|
(() => {
|
||||||
const selectedKb = knowledgeBases.find(
|
const selectedKb = validKnowledgeBases.find(
|
||||||
(kb) => kb.uuid === field.value,
|
(kb) => kb.uuid === field.value,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -1224,7 +1237,7 @@ export default function DynamicFormItemComponent({
|
|||||||
<SelectGroup key={engineName}>
|
<SelectGroup key={engineName}>
|
||||||
<SelectLabel>{engineName}</SelectLabel>
|
<SelectLabel>{engineName}</SelectLabel>
|
||||||
{kbs.map((base) => (
|
{kbs.map((base) => (
|
||||||
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
|
<SelectItem key={base.uuid} value={base.uuid}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{base.emoji && (
|
{base.emoji && (
|
||||||
<span className="text-sm shrink-0">{base.emoji}</span>
|
<span className="text-sm shrink-0">{base.emoji}</span>
|
||||||
@@ -1241,7 +1254,8 @@ export default function DynamicFormItemComponent({
|
|||||||
|
|
||||||
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
|
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
|
||||||
// Group KBs by Knowledge Engine name for 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) => {
|
(acc, kb) => {
|
||||||
const engineName = kb.knowledge_engine?.name
|
const engineName = kb.knowledge_engine?.name
|
||||||
? extractI18nObject(kb.knowledge_engine.name)
|
? extractI18nObject(kb.knowledge_engine.name)
|
||||||
@@ -1252,7 +1266,7 @@ export default function DynamicFormItemComponent({
|
|||||||
acc[engineName].push(kb);
|
acc[engineName].push(kb);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, typeof knowledgeBases>,
|
{} as Record<string, typeof validMultiKnowledgeBases>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1261,7 +1275,7 @@ export default function DynamicFormItemComponent({
|
|||||||
{field.value && field.value.length > 0 ? (
|
{field.value && field.value.length > 0 ? (
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
{field.value.map((kbId: string) => {
|
{field.value.map((kbId: string) => {
|
||||||
const currentKb = knowledgeBases.find(
|
const currentKb = validMultiKnowledgeBases.find(
|
||||||
(base) => base.uuid === kbId,
|
(base) => base.uuid === kbId,
|
||||||
);
|
);
|
||||||
if (!currentKb) return null;
|
if (!currentKb) return null;
|
||||||
@@ -1348,14 +1362,14 @@ export default function DynamicFormItemComponent({
|
|||||||
</div>
|
</div>
|
||||||
{kbs.map((base) => {
|
{kbs.map((base) => {
|
||||||
const isSelected = tempSelectedKBIds.includes(
|
const isSelected = tempSelectedKBIds.includes(
|
||||||
base.uuid ?? '',
|
base.uuid,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={base.uuid}
|
key={base.uuid}
|
||||||
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const kbId = base.uuid ?? '';
|
const kbId = base.uuid;
|
||||||
setTempSelectedKBIds((prev) =>
|
setTempSelectedKBIds((prev) =>
|
||||||
prev.includes(kbId)
|
prev.includes(kbId)
|
||||||
? prev.filter((id) => id !== kbId)
|
? prev.filter((id) => id !== kbId)
|
||||||
@@ -1417,8 +1431,8 @@ export default function DynamicFormItemComponent({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{bots.map((bot) => (
|
{bots.filter(hasUsableUuid).map((bot) => (
|
||||||
<SelectItem key={bot.uuid} value={bot.uuid ?? ''}>
|
<SelectItem key={bot.uuid} value={bot.uuid}>
|
||||||
{bot.name}
|
{bot.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,63 +12,15 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData';
|
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 { 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 {
|
interface PipelineMonitoringTabProps {
|
||||||
pipelineId: string;
|
pipelineId: string;
|
||||||
onNavigateToMonitoring?: () => void;
|
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({
|
export default function PipelineMonitoringTab({
|
||||||
pipelineId,
|
pipelineId,
|
||||||
onNavigateToMonitoring,
|
onNavigateToMonitoring,
|
||||||
@@ -88,98 +40,24 @@ export default function PipelineMonitoringTab({
|
|||||||
|
|
||||||
const { data, loading, refetch } = useMonitoringData(filterState);
|
const { data, loading, refetch } = useMonitoringData(filterState);
|
||||||
|
|
||||||
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
const conversationTurns = useMemo(
|
||||||
null,
|
() =>
|
||||||
);
|
data
|
||||||
const [messageDetails, setMessageDetails] = useState<
|
? buildConversationTurns(
|
||||||
Record<string, MessageDetails>
|
data.messages,
|
||||||
>({});
|
data.llmCalls,
|
||||||
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(
|
data.errors,
|
||||||
{},
|
data.toolCalls,
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
[data],
|
||||||
);
|
);
|
||||||
|
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
|
||||||
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
|
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<string>('messages');
|
const [activeTab, setActiveTab] = useState<string>('messages');
|
||||||
|
|
||||||
const toggleMessageExpand = async (messageId: string) => {
|
const toggleTurnExpand = (turnId: string) => {
|
||||||
if (expandedMessageId === messageId) {
|
setExpandedTurnId((current) => (current === turnId ? null : turnId));
|
||||||
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 toggleErrorExpand = (errorId: string) => {
|
const toggleErrorExpand = (errorId: string) => {
|
||||||
@@ -190,12 +68,16 @@ export default function PipelineMonitoringTab({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const jumpToMessage = async (messageId: string) => {
|
const jumpToMessage = (messageId: string) => {
|
||||||
setActiveTab('messages');
|
setActiveTab('messages');
|
||||||
// Small delay to ensure tab transition completes before expanding
|
|
||||||
setTimeout(() => {
|
const turn = conversationTurns.find((item) =>
|
||||||
toggleMessageExpand(messageId);
|
item.messages.some((message) => message.id === messageId),
|
||||||
}, 100);
|
);
|
||||||
|
|
||||||
|
if (turn) {
|
||||||
|
setExpandedTurnId(turn.id);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -295,142 +177,22 @@ export default function PipelineMonitoringTab({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && data && data.messages && data.messages.length > 0 && (
|
{!loading && data && conversationTurns.length > 0 && (
|
||||||
<div className="space-y-3">
|
<ConversationTurnList
|
||||||
{data.messages
|
turns={conversationTurns}
|
||||||
.filter((msg) => {
|
expandedTurnId={expandedTurnId}
|
||||||
const content = msg.messageContent?.trim();
|
onToggleTurn={toggleTurnExpand}
|
||||||
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 &&
|
{!loading && (!data || conversationTurns.length === 0) && (
|
||||||
(!data || !data.messages || data.messages.length === 0) && (
|
<div className="text-center text-gray-500 dark:text-gray-400 py-16">
|
||||||
<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" />
|
||||||
<MessageCircle className="w-16 h-16 mx-auto mb-4 text-gray-300 dark:text-gray-600" />
|
<p className="text-base font-medium">
|
||||||
<p className="text-base font-medium">
|
{t('monitoring.messageList.noMessages')}
|
||||||
{t('monitoring.messageList.noMessages')}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Errors Tab */}
|
{/* Errors Tab */}
|
||||||
|
|||||||
@@ -88,6 +88,31 @@ test.describe('frontend CRUD smoke flows', () => {
|
|||||||
).toBeVisible();
|
).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 }) => {
|
test('creates, edits, and deletes a knowledge base', async ({ page }) => {
|
||||||
await installLangBotApiMocks(page, { authenticated: true });
|
await installLangBotApiMocks(page, { authenticated: true });
|
||||||
|
|
||||||
|
|||||||
@@ -194,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() {
|
function knowledgeEngine() {
|
||||||
return {
|
return {
|
||||||
plugin_id: 'builtin/minimal-knowledge',
|
plugin_id: 'builtin/minimal-knowledge',
|
||||||
@@ -395,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') {
|
if (path === '/api/v1/pipelines/_/metadata') {
|
||||||
return fulfillJson(route, { configs: [] });
|
return fulfillJson(route, pipelineMetadata());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/v1/pipelines') {
|
if (path === '/api/v1/pipelines') {
|
||||||
|
|||||||
@@ -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