From 0ce382fc8b8dd07bc05d3fcd9d9855f8cab11600 Mon Sep 17 00:00:00 2001 From: Hyu Date: Thu, 2 Jul 2026 16:34:11 +0800 Subject: [PATCH] Improve pipeline monitoring and AI tab resilience --- .../dynamic-form/DynamicFormItemComponent.tsx | 64 ++-- .../monitoring-tab/PipelineMonitoringTab.tsx | 316 +++--------------- web/tests/e2e/crud-smoke.spec.ts | 25 ++ web/tests/e2e/fixtures/langbot-api.ts | 110 +++++- .../e2e/pipeline-monitoring-turns.spec.ts | 195 +++++++++++ 5 files changed, 407 insertions(+), 303 deletions(-) create mode 100644 web/tests/e2e/pipeline-monitoring-turns.spec.ts diff --git a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx index 18f0e7452..9cb01d1c5 100644 --- a/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx +++ b/web/src/app/home/components/dynamic-form/DynamicFormItemComponent.tsx @@ -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( + 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({ - {config.options?.map((option) => ( - - {extractI18nObject(option.label)} - - ))} + {config.options + ?.filter(hasUsableOptionName) + .map((option) => ( + + {extractI18nObject(option.label)} + + ))} @@ -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, + {} as Record, ); return ( @@ -1195,7 +1208,7 @@ export default function DynamicFormItemComponent({ {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({ {engineName} {kbs.map((base) => ( - +
{base.emoji && ( {base.emoji} @@ -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, + {} as Record, ); return ( @@ -1261,7 +1275,7 @@ export default function DynamicFormItemComponent({ {field.value && field.value.length > 0 ? (
{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({
{kbs.map((base) => { const isSelected = tempSelectedKBIds.includes( - base.uuid ?? '', + base.uuid, ); return (
{ - 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({ - {bots.map((bot) => ( - + {bots.filter(hasUsableUuid).map((bot) => ( + {bot.name} ))} diff --git a/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx b/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx index 9364d9fea..a57c5cdd8 100644 --- a/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx +++ b/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx @@ -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; -} - -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( - null, - ); - const [messageDetails, setMessageDetails] = useState< - Record - >({}); - const [loadingDetails, setLoadingDetails] = useState>( - {}, + const conversationTurns = useMemo( + () => + data + ? buildConversationTurns( + data.messages, + data.llmCalls, + data.errors, + data.toolCalls, + ) + : [], + [data], ); + const [expandedTurnId, setExpandedTurnId] = useState(null); const [expandedErrorId, setExpandedErrorId] = useState(null); const [activeTab, setActiveTab] = useState('messages'); - const toggleMessageExpand = async (messageId: string) => { - if (expandedMessageId === messageId) { - setExpandedMessageId(null); - } else { - setExpandedMessageId(messageId); - - if (!messageDetails[messageId]) { - setLoadingDetails((prev) => ({ ...prev, [messageId]: true })); - try { - const result = await httpClient.get<{ - message_id: string; - found: boolean; - message: RawMessageData | null; - llm_calls: RawLLMCallData[]; - llm_stats: RawLLMStatsData; - errors: RawErrorData[]; - }>(`/api/v1/monitoring/messages/${messageId}/details`); - - if (result) { - setMessageDetails((prev) => ({ - ...prev, - [messageId]: { - messageId: result.message_id, - found: result.found, - message: result.message - ? { - id: result.message.id, - timestamp: 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({
)} - {!loading && data && data.messages && data.messages.length > 0 && ( -
- {data.messages - .filter((msg) => { - const content = msg.messageContent?.trim(); - return content && content !== '[]' && content !== '""'; - }) - .map((msg) => ( -
-
toggleMessageExpand(msg.id)} - > -
-
-
- {expandedMessageId === msg.id ? ( - - ) : ( - - )} -
-
-
- - {msg.status} - - - {msg.botName} - -
-
- -
-
-
- - {msg.timestamp.toLocaleString()} - -
-
- - {expandedMessageId === msg.id && ( -
- {loadingDetails[msg.id] && ( -
- -
- )} - - {!loadingDetails[msg.id] && - messageDetails[msg.id] && ( -
- {messageDetails[msg.id].errors.length > 0 && ( -
-

- {t('monitoring.errors.errorMessage')} -

- {messageDetails[msg.id].errors.map( - (error) => ( -
-
- {error.errorType}:{' '} - {error.errorMessage} -
- {error.stackTrace && ( -
-                                              {error.stackTrace}
-                                            
- )} -
- ), - )} -
- )} - - {messageDetails[msg.id].llmCalls.length > 0 && ( -
-

- {t('monitoring.tabs.modelCalls')} ( - {messageDetails[msg.id].llmCalls.length}) -

-
-
- {t('monitoring.llmCalls.totalTokens')}:{' '} - { - messageDetails[msg.id].llmStats - .totalTokens - } -
-
- {t('monitoring.llmCalls.duration')}:{' '} - {messageDetails[ - msg.id - ].llmStats.totalDurationMs.toFixed(0)} - ms -
-
-
- )} -
- )} -
- )} -
- ))} -
+ {!loading && data && conversationTurns.length > 0 && ( + )} - {!loading && - (!data || !data.messages || data.messages.length === 0) && ( -
- -

- {t('monitoring.messageList.noMessages')} -

-
- )} + {!loading && (!data || conversationTurns.length === 0) && ( +
+ +

+ {t('monitoring.messageList.noMessages')} +

+
+ )} {/* Errors Tab */} diff --git a/web/tests/e2e/crud-smoke.spec.ts b/web/tests/e2e/crud-smoke.spec.ts index bba7467b3..863c8525e 100644 --- a/web/tests/e2e/crud-smoke.spec.ts +++ b/web/tests/e2e/crud-smoke.spec.ts @@ -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 { await installLangBotApiMocks(page, { authenticated: true }); diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index 20f21086d..fda101148 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -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() { return { 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') { - return fulfillJson(route, { configs: [] }); + return fulfillJson(route, pipelineMetadata()); } if (path === '/api/v1/pipelines') { diff --git a/web/tests/e2e/pipeline-monitoring-turns.spec.ts b/web/tests/e2e/pipeline-monitoring-turns.spec.ts new file mode 100644 index 000000000..5ab4f9330 --- /dev/null +++ b/web/tests/e2e/pipeline-monitoring-turns.spec.ts @@ -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(); + }); +});