diff --git a/web/app/components/app/workflow-log/__tests__/evaluation-cell.spec.tsx b/web/app/components/app/workflow-log/__tests__/evaluation-cell.spec.tsx new file mode 100644 index 0000000000..e51ffdaaac --- /dev/null +++ b/web/app/components/app/workflow-log/__tests__/evaluation-cell.spec.tsx @@ -0,0 +1,75 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import EvaluationCell from '../evaluation-cell' + +describe('EvaluationCell', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('Rendering', () => { + it('should render a placeholder when evaluation data is empty', () => { + render() + + expect(screen.getByText('-')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'appLog.table.header.evaluation' })).not.toBeInTheDocument() + }) + + it('should render a trigger button when evaluation data is available', () => { + render( + , + ) + + expect(screen.getByRole('button', { name: 'appLog.table.header.evaluation' })).toBeInTheDocument() + }) + }) + + describe('Interactions', () => { + it('should render evaluation details when clicking the trigger', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' })) + + expect(await screen.findByTestId('workflow-log-evaluation-popover')).toBeInTheDocument() + expect(screen.getByText('Faithfulness')).toBeInTheDocument() + expect(screen.getByText('0.98')).toBeInTheDocument() + expect(screen.getByText('Knowledge Retrieval')).toBeInTheDocument() + }) + + it('should render boolean values using readable text', async () => { + const user = userEvent.setup() + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' })) + + expect(await screen.findByText('True')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app/workflow-log/__tests__/index.spec.tsx b/web/app/components/app/workflow-log/__tests__/index.spec.tsx index 1fe3db30db..4414843ed0 100644 --- a/web/app/components/app/workflow-log/__tests__/index.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/index.spec.tsx @@ -215,6 +215,7 @@ const createMockWorkflowLog = (overrides: Partial = {}): W }, created_at: Date.now(), ...overrides, + evaluation: overrides.evaluation ?? [], }) const createMockLogsResponse = ( diff --git a/web/app/components/app/workflow-log/__tests__/list.spec.tsx b/web/app/components/app/workflow-log/__tests__/list.spec.tsx index 356aaa5a48..1246096cf5 100644 --- a/web/app/components/app/workflow-log/__tests__/list.spec.tsx +++ b/web/app/components/app/workflow-log/__tests__/list.spec.tsx @@ -146,6 +146,7 @@ const createMockWorkflowLog = (overrides: Partial = {}): W email: 'test@example.com', }, created_at: Date.now(), + evaluation: [], ...overrides, }) @@ -216,6 +217,7 @@ describe('WorkflowAppLogList', () => { expect(screen.getByText('appLog.table.header.runtime')).toBeInTheDocument() expect(screen.getByText('appLog.table.header.tokens')).toBeInTheDocument() expect(screen.getByText('appLog.table.header.user')).toBeInTheDocument() + expect(screen.getByText('appLog.table.header.evaluation')).toBeInTheDocument() }) it('should render trigger column for workflow apps', () => { @@ -404,8 +406,9 @@ describe('WorkflowAppLogList', () => { // Arrow should rotate (indicated by class change) // The sort icon should have rotate-180 class for ascending - const sortIcon = startTimeHeader.closest('div')?.querySelector('svg') + const sortIcon = startTimeHeader.closest('div')?.querySelector('.i-heroicons-arrow-down') expect(sortIcon).toBeInTheDocument() + expect(sortIcon).toHaveClass('rotate-180') }) it('should render sort arrow icon', () => { @@ -416,7 +419,7 @@ describe('WorkflowAppLogList', () => { ) // Check for ArrowDownIcon presence - const sortArrow = container.querySelector('svg.ml-0\\.5') + const sortArrow = container.querySelector('.i-heroicons-arrow-down') expect(sortArrow).toBeInTheDocument() }) }) @@ -491,6 +494,34 @@ describe('WorkflowAppLogList', () => { // The row should have the selected class expect(dataRow).toHaveClass('bg-background-default-hover') }) + + it('should open evaluation popover without opening drawer when clicking evaluation trigger', async () => { + const user = userEvent.setup() + const logs = createMockLogsResponse([ + createMockWorkflowLog({ + evaluation: [{ + name: 'Faithfulness', + value: 0.98, + nodeInfo: { + node_id: 'node-1', + title: 'Knowledge Retrieval', + type: 'knowledge-retrieval', + }, + }], + }), + ]) + + render( + , + ) + + await user.click(screen.getByRole('button', { name: 'appLog.table.header.evaluation' })) + + expect(await screen.findByTestId('workflow-log-evaluation-popover')).toBeInTheDocument() + expect(screen.getByText('Faithfulness')).toBeInTheDocument() + expect(screen.getByText('Knowledge Retrieval')).toBeInTheDocument() + expect(screen.queryByRole('heading', { name: 'appLog.runDetail.workflowTitle' })).not.toBeInTheDocument() + }) }) // -------------------------------------------------------------------------- diff --git a/web/app/components/app/workflow-log/evaluation-cell.tsx b/web/app/components/app/workflow-log/evaluation-cell.tsx new file mode 100644 index 0000000000..8cb3770681 --- /dev/null +++ b/web/app/components/app/workflow-log/evaluation-cell.tsx @@ -0,0 +1,100 @@ +'use client' + +import type { EvaluationLogItem } from '@/models/log' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/app/components/base/ui/popover' +import { getNodeVisual, getToneClasses } from '@/app/components/evaluation/components/metric-selector/utils' +import { cn } from '@/utils/classnames' + +type EvaluationCellProps = { + evaluation: EvaluationLogItem[] +} + +const formatEvaluationValue = (value: EvaluationLogItem['value']) => { + if (typeof value === 'boolean') + return value ? 'True' : 'False' + + return String(value) +} + +const EvaluationCell = ({ + evaluation, +}: EvaluationCellProps) => { + const { t } = useTranslation() + const [open, setOpen] = useState(false) + + if (!evaluation.length) { + return ( +
+ - +
+ ) + } + + return ( + + + + ) +} + +export default EvaluationCell diff --git a/web/app/components/app/workflow-log/list.tsx b/web/app/components/app/workflow-log/list.tsx index 18ec914c7d..36033db534 100644 --- a/web/app/components/app/workflow-log/list.tsx +++ b/web/app/components/app/workflow-log/list.tsx @@ -2,8 +2,6 @@ import type { FC } from 'react' import type { WorkflowAppLogDetail, WorkflowLogsResponse, WorkflowRunTriggeredFrom } from '@/models/log' import type { App } from '@/types/app' -import { ArrowDownIcon } from '@heroicons/react/24/outline' -import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import Drawer from '@/app/components/base/drawer' @@ -14,6 +12,7 @@ import useTimestamp from '@/hooks/use-timestamp' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' import DetailPanel from './detail' +import EvaluationCell from './evaluation-cell' import TriggerByDisplay from './trigger-by-display' type ILogs = { @@ -59,7 +58,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { const statusTdRender = (status: string) => { if (status === 'succeeded') { return ( -
+
Success
@@ -67,7 +66,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { } if (status === 'failed') { return ( -
+
Failure
@@ -75,7 +74,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { } if (status === 'stopped') { return ( -
+
Stop
@@ -83,7 +82,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { } if (status === 'paused') { return ( -
+
Pending
@@ -91,7 +90,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { } if (status === 'running') { return ( -
+
Running
@@ -99,7 +98,7 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { } if (status === 'partial-succeeded') { return ( -
+
Partial Success
@@ -118,23 +117,22 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { return (
- +
- - + - - - - - {isWorkflow && } + + + + + + {isWorkflow && } @@ -172,6 +170,9 @@ const WorkflowAppLogList: FC = ({ logs, appDetail, onRefresh }) => { {endUser} + {isWorkflow && (
+
{t('table.header.startTime', { ns: 'appLog' })} - +
{t('table.header.status', { ns: 'appLog' })}{t('table.header.runtime', { ns: 'appLog' })}{t('table.header.tokens', { ns: 'appLog' })}{t('table.header.user', { ns: 'appLog' })}{t('table.header.triggered_from', { ns: 'appLog' })}{t('table.header.status', { ns: 'appLog' })}{t('table.header.runtime', { ns: 'appLog' })}{t('table.header.tokens', { ns: 'appLog' })}{t('table.header.user', { ns: 'appLog' })}{t('table.header.evaluation', { ns: 'appLog' })}{t('table.header.triggered_from', { ns: 'appLog' })}
event.stopPropagation()}> + + diff --git a/web/i18n/en-US/app-log.json b/web/i18n/en-US/app-log.json index 3ffb8ba99e..7cd62958e4 100644 --- a/web/i18n/en-US/app-log.json +++ b/web/i18n/en-US/app-log.json @@ -54,6 +54,7 @@ "table.empty.noOutput": "No output", "table.header.adminRate": "Op. Rate", "table.header.endUser": "End User or Account", + "table.header.evaluation": "EVALUATION", "table.header.input": "Input", "table.header.messageCount": "Message Count", "table.header.output": "Output", diff --git a/web/i18n/zh-Hans/app-log.json b/web/i18n/zh-Hans/app-log.json index ef043d1b70..0a825da172 100644 --- a/web/i18n/zh-Hans/app-log.json +++ b/web/i18n/zh-Hans/app-log.json @@ -54,6 +54,7 @@ "table.empty.noOutput": "无输出", "table.header.adminRate": "管理员反馈", "table.header.endUser": "用户或账户", + "table.header.evaluation": "评测", "table.header.input": "输入", "table.header.messageCount": "消息数", "table.header.output": "输出", diff --git a/web/models/log.ts b/web/models/log.ts index f9cb13ab8e..e41b66821f 100644 --- a/web/models/log.ts +++ b/web/models/log.ts @@ -257,6 +257,15 @@ export type EndUserInfo = { is_anonymous: boolean session_id: string } +export type EvaluationLogItem = { + name: string + value: string | number | boolean + nodeInfo?: { + node_id: string + type: string + title: string + } +} export type WorkflowAppLogDetail = { id: string workflow_run: WorkflowRunDetail @@ -267,6 +276,7 @@ export type WorkflowAppLogDetail = { created_by_end_user?: EndUserInfo created_at: number read_at?: number + evaluation: EvaluationLogItem[] } export type WorkflowLogsResponse = { data: Array