mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-14 16: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 { 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({
|
||||
config,
|
||||
field,
|
||||
@@ -104,7 +114,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);
|
||||
@@ -115,7 +125,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);
|
||||
@@ -126,7 +136,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);
|
||||
@@ -230,7 +240,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);
|
||||
@@ -243,7 +253,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);
|
||||
@@ -388,15 +398,17 @@ export default function DynamicFormItemComponent({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
{config.options?.map((option) => (
|
||||
<SelectItem
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
description={option.name}
|
||||
>
|
||||
{extractI18nObject(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{config.options
|
||||
?.filter(hasUsableOptionName)
|
||||
.map((option) => (
|
||||
<SelectItem
|
||||
key={option.name}
|
||||
value={option.name}
|
||||
description={option.name}
|
||||
>
|
||||
{extractI18nObject(option.label)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -1176,7 +1188,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)
|
||||
@@ -1187,7 +1200,7 @@ export default function DynamicFormItemComponent({
|
||||
acc[engineName].push(kb);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof knowledgeBases>,
|
||||
{} as Record<string, typeof validKnowledgeBases>,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -1195,7 +1208,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 (
|
||||
@@ -1224,7 +1237,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>
|
||||
@@ -1241,7 +1254,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)
|
||||
@@ -1252,7 +1266,7 @@ export default function DynamicFormItemComponent({
|
||||
acc[engineName].push(kb);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof knowledgeBases>,
|
||||
{} as Record<string, typeof validMultiKnowledgeBases>,
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -1261,7 +1275,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;
|
||||
@@ -1348,14 +1362,14 @@ export default function DynamicFormItemComponent({
|
||||
</div>
|
||||
{kbs.map((base) => {
|
||||
const isSelected = tempSelectedKBIds.includes(
|
||||
base.uuid ?? '',
|
||||
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)
|
||||
@@ -1417,8 +1431,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>
|
||||
))}
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user