diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 09ee039cc58..274f07153d0 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -593,17 +593,9 @@ "count": 2 } }, - "web/app/components/base/agent-log-modal/detail.tsx": { - "typescript/no-explicit-any": { - "count": 1 - } - }, "web/app/components/base/agent-log-modal/index.stories.tsx": { "no-console": { "count": 1 - }, - "typescript/no-explicit-any": { - "count": 1 } }, "web/app/components/base/agent-log-modal/index.tsx": { @@ -1141,17 +1133,9 @@ "count": 4 } }, - "web/app/components/base/message-log-modal/__tests__/index.spec.tsx": { - "dify/require-disable-directive-description": { - "count": 1 - } - }, "web/app/components/base/message-log-modal/index.stories.tsx": { "no-console": { "count": 1 - }, - "typescript/no-explicit-any": { - "count": 1 } }, "web/app/components/base/new-audio-button/index.tsx": { diff --git a/web/app/components/app/configuration/debug/index.tsx b/web/app/components/app/configuration/debug/index.tsx index 5a65ea2a54f..01f561aea73 100644 --- a/web/app/components/app/configuration/debug/index.tsx +++ b/web/app/components/app/configuration/debug/index.tsx @@ -638,6 +638,7 @@ const Debug: FC = ({ {selectedLogItem && (selectedLogItem.agent_thoughts?.length ? ( setSelectedLogItem(undefined)} diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index df1f4486d2f..f30b70bb89d 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -588,6 +588,7 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { {logKind === 'workflow' && ( setSelectedLogItem(undefined)} @@ -596,6 +597,7 @@ function DetailPanel({ appDetail, detail, onClose, onFeedback }: IDetailPanel) { )} {logKind === 'agent' && ( ({ toast: mockToast, })) -vi.mock('@/app/components/app/store', () => ({ - useStore: vi.fn((selector) => selector({ appDetail: { id: 'app-id' } })), -})) - vi.mock('@/app/components/workflow/run/status', () => ({ default: ({ status, @@ -116,6 +111,7 @@ const createMockResponse = ( describe('AgentLogDetail', () => { const renderComponent = (props: Partial> = {}) => { const defaultProps: ComponentProps = { + appId: 'app-id', conversationID: 'conv-id', messageID: 'msg-id', log: createMockLog(), @@ -228,20 +224,6 @@ describe('AgentLogDetail', () => { }) describe('Edge Cases', () => { - it('should not fetch data when app detail is unavailable', async () => { - vi.mocked(useAppStore).mockImplementationOnce((selector) => - selector({ appDetail: undefined } as never), - ) - vi.mocked(fetchAgentLogDetail).mockResolvedValue(createMockResponse()) - - renderComponent() - - await waitFor(() => { - expect(fetchAgentLogDetail).not.toHaveBeenCalled() - }) - expect(screen.getByRole('progressbar')).toBeInTheDocument() - }) - it('should notify on API error', async () => { vi.mocked(fetchAgentLogDetail).mockRejectedValue(new Error('API Error')) diff --git a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx index 4e645a3192a..28d814de454 100644 --- a/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx +++ b/web/app/components/base/agent-log-modal/__tests__/index.spec.tsx @@ -1,5 +1,5 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type' -import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' import { useClickAway } from 'ahooks' import { fetchAgentLogDetail } from '@/service/log' import AgentLogModal from '../index' @@ -25,10 +25,6 @@ vi.mock('@/app/notifications', () => ({ toast: mockToast, })) -vi.mock('@/app/components/app/store', () => ({ - useStore: vi.fn((selector) => selector({ appDetail: { id: 'app-id' } })), -})) - vi.mock('@/app/components/workflow/run/status', () => ({ default: ({ status, @@ -122,19 +118,25 @@ describe('AgentLogModal', () => { }) it('should return null if no currentLogItem', () => { - const { container } = render() + const { container } = render( + , + ) expect(container.firstChild).toBeNull() }) it('should return null if no conversationId', () => { const { container } = render( - , + , ) expect(container.firstChild).toBeNull() }) it('should render correctly when log item is provided', async () => { - render() + render() expect(screen.getByText('appLog.runDetail.workflowTitle')).toBeInTheDocument() @@ -146,7 +148,7 @@ describe('AgentLogModal', () => { it('should render the floating modal through a dialog portal', () => { vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) - const { container } = render() + const { container } = render() const modal = screen.getByRole('dialog') expect(container).not.toContainElement(modal) @@ -157,7 +159,7 @@ describe('AgentLogModal', () => { it('should call onCancel when close button is clicked', () => { vi.mocked(fetchAgentLogDetail).mockReturnValue(new Promise(() => {})) - render() + render() const closeBtn = screen.getByRole('button', { name: 'common.operation.close' }) fireEvent.click(closeBtn) @@ -173,7 +175,7 @@ describe('AgentLogModal', () => { clickAwayHandler = callback }) - render() + render() clickAwayHandler(new Event('click')) expect(mockProps.onCancel).toHaveBeenCalledTimes(1) @@ -189,7 +191,7 @@ describe('AgentLogModal', () => { } }) - render() + render() expect(mockProps.onCancel).not.toHaveBeenCalled() }) @@ -202,9 +204,83 @@ describe('AgentLogModal', () => { clickAwayHandler = callback }) - render() + render() clickAwayHandler(new Event('click')) expect(mockProps.onCancel).not.toHaveBeenCalled() }) + + it('keeps the new app log visible when the previous app response arrives late', async () => { + const response = { + meta: { + status: 'succeeded', + executor: 'New executor', + start_time: '2023-01-01', + elapsed_time: 1, + total_tokens: 1, + agent_mode: 'function_call', + iterations: 0, + }, + iterations: [], + files: [], + } + let resolveOldResponse!: (value: Awaited>) => void + const pending = new Promise>>((resolve) => { + resolveOldResponse = resolve + }) + vi.mocked(fetchAgentLogDetail) + .mockResolvedValue(response) + .mockImplementationOnce(() => pending) + const { rerender } = render() + rerender() + await screen.findByText('New executor') + expect(fetchAgentLogDetail).toHaveBeenLastCalledWith({ + appID: 'new-app', + params: { conversation_id: mockLog.conversationId, message_id: mockLog.id }, + }) + await act(async () => { + resolveOldResponse({ ...response, meta: { ...response.meta, executor: 'Old executor' } }) + await pending + }) + expect(screen.getByText('New executor')).toBeInTheDocument() + expect(screen.queryByText('Old executor')).not.toBeInTheDocument() + }) + it('preserves the selected tab when another message replaces the open record', async () => { + const { rerender } = render() + await screen.findByText('User') + fireEvent.click(screen.getByRole('button', { name: 'runLog.tracing' })) + rerender( + , + ) + expect(screen.getByRole('button', { name: 'runLog.tracing' })).toHaveAttribute( + 'data-active', + 'true', + ) + expect(screen.getByRole('progressbar')).toBeInTheDocument() + await waitFor(() => expect(screen.queryByRole('progressbar')).not.toBeInTheDocument()) + expect(fetchAgentLogDetail).toHaveBeenLastCalledWith({ + appID: 'app-id', + params: { conversation_id: mockLog.conversationId, message_id: 'next-message' }, + }) + }) + + it('ignores a failed request after its record has been replaced', async () => { + let rejectOldResponse: (error: Error) => void = () => {} + const pending = new Promise>>((_, reject) => { + rejectOldResponse = reject + }) + vi.mocked(fetchAgentLogDetail).mockReturnValueOnce(pending) + const { rerender } = render() + rerender() + await screen.findByText('User') + await act(async () => { + rejectOldResponse(new Error('Old request failed')) + }) + expect(mockToast.error).not.toHaveBeenCalled() + expect(screen.getByText('User')).toBeInTheDocument() + }) }) diff --git a/web/app/components/base/agent-log-modal/detail.tsx b/web/app/components/base/agent-log-modal/detail.tsx index f56bc612ff8..b79987b690d 100644 --- a/web/app/components/base/agent-log-modal/detail.tsx +++ b/web/app/components/base/agent-log-modal/detail.tsx @@ -1,14 +1,12 @@ 'use client' import type { FC } from 'react' import type { IChatItem } from '@/app/components/base/chat/chat/type' -import type { AgentIteration, AgentLogDetailResponse } from '@/models/log' +import type { AgentLogDetailResponse } from '@/models/log' import { cn } from '@langgenius/dify-ui/cn' import { uniq } from 'es-toolkit/array' -import { flatten } from 'es-toolkit/compat' import * as React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useStore as useAppStore } from '@/app/components/app/store' import { LoadingPlaceholder } from '@/app/components/base/loading-placeholder' import { toast } from '@/app/notifications' import { fetchAgentLogDetail } from '@/service/log' @@ -16,63 +14,21 @@ import ResultPanel from './result' import TracingPanel from './tracing' type AgentLogDetailProps = Readonly<{ + appId: string activeTab?: 'DETAIL' | 'TRACING' conversationID: string log: IChatItem messageID: string }> const AgentLogDetail: FC = ({ + appId, activeTab = 'DETAIL', conversationID, messageID, log, }) => { const { t } = useTranslation(['runLog']) - const [currentTab, setCurrentTab] = useState(activeTab) - const appDetail = useAppStore((s) => s.appDetail) - const [loading, setLoading] = useState(true) - const [runDetail, setRunDetail] = useState() - const [list, setList] = useState([]) - const tools = useMemo(() => { - const res = uniq( - flatten( - runDetail?.iterations.map((iteration) => { - return iteration.tool_calls.map((tool: any) => tool.tool_name).filter(Boolean) - }), - ).filter(Boolean), - ) - return res - }, [runDetail]) - const getLogDetail = useCallback( - async (appID: string, conversationID: string, messageID: string) => { - try { - const res = await fetchAgentLogDetail({ - appID, - params: { - conversation_id: conversationID, - message_id: messageID, - }, - }) - setRunDetail(res) - setList(res.iterations) - } catch (err) { - toast.error(`${err}`) - } - }, - [], - ) - const getData = async (appID: string, conversationID: string, messageID: string) => { - setLoading(true) - await getLogDetail(appID, conversationID, messageID) - setLoading(false) - } - const switchTab = async (tab: string) => { - setCurrentTab(tab) - } - useEffect(() => { - // fetch data - if (appDetail) getData(appDetail.id, conversationID, messageID) - }, [appDetail, conversationID, messageID]) + const [currentTab, setCurrentTab] = useState<'DETAIL' | 'TRACING'>(activeTab) return (
{/* tab */} @@ -84,7 +40,7 @@ const AgentLogDetail: FC = ({ currentTab === 'DETAIL' && 'border-[rgb(21,94,239)]! text-text-secondary', )} data-active={currentTab === 'DETAIL'} - onClick={() => switchTab('DETAIL')} + onClick={() => setCurrentTab('DETAIL')} > {t(($) => $.detail, { ns: 'runLog' })} @@ -95,41 +51,99 @@ const AgentLogDetail: FC = ({ currentTab === 'TRACING' && 'border-[rgb(21,94,239)]! text-text-secondary', )} data-active={currentTab === 'TRACING'} - onClick={() => switchTab('TRACING')} + onClick={() => setCurrentTab('TRACING')} > {t(($) => $.tracing, { ns: 'runLog' })}
- {/* panel detail */} -
- {loading && ( -
- -
- )} - {!loading && currentTab === 'DETAIL' && runDetail && ( - - )} - {!loading && currentTab === 'TRACING' && } -
+ ) } +type AgentLogContentProps = Omit & { + currentTab: 'DETAIL' | 'TRACING' +} + +function AgentLogContent({ + appId, + conversationID, + messageID, + log, + currentTab, +}: AgentLogContentProps) { + const [loading, setLoading] = useState(true) + const [runDetail, setRunDetail] = useState() + const tools = useMemo( + () => + uniq( + (runDetail?.iterations ?? []).flatMap((iteration) => + iteration.tool_calls + .map((tool) => tool.tool_name) + .filter((name): name is string => !!name), + ), + ), + [runDetail], + ) + + useEffect(() => { + let active = true + fetchAgentLogDetail({ + appID: appId, + params: { conversation_id: conversationID, message_id: messageID }, + }).then( + (data) => { + if (!active) return + setRunDetail(data) + setLoading(false) + }, + (error) => { + if (!active) return + toast.error(`${error}`) + setLoading(false) + }, + ) + return () => { + active = false + } + }, [appId, conversationID, messageID]) + + return ( +
+ {loading && ( +
+ +
+ )} + {!loading && currentTab === 'DETAIL' && runDetail && ( + + )} + {!loading && currentTab === 'TRACING' && } +
+ ) +} + export default AgentLogDetail diff --git a/web/app/components/base/agent-log-modal/index.stories.tsx b/web/app/components/base/agent-log-modal/index.stories.tsx index 8279a45e37d..fbf8874471c 100644 --- a/web/app/components/base/agent-log-modal/index.stories.tsx +++ b/web/app/components/base/agent-log-modal/index.stories.tsx @@ -2,7 +2,6 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' import type { IChatItem } from '@/app/components/base/chat/chat/type' import type { AgentLogDetailResponse } from '@/models/log' import { useEffect, useRef } from 'react' -import { useStore as useAppStore } from '@/app/components/app/store' import { AppToastHost } from '@/app/notifications/host' import AgentLogModal from '.' @@ -66,15 +65,8 @@ const MOCK_CHAT_ITEM: IChatItem = { const AgentLogModalDemo = ({ width = 960 }: { width?: number }) => { const originalFetchRef = useRef(null) - const setAppDetail = useAppStore((state) => state.setAppDetail) useEffect(() => { - setAppDetail({ - id: 'app-1', - name: 'Analytics Agent', - mode: 'agent-chat', - } as any) - originalFetchRef.current = globalThis.fetch?.bind(globalThis) const handler = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -98,15 +90,15 @@ const AgentLogModalDemo = ({ width = 960 }: { width?: number }) => { return () => { if (originalFetchRef.current) globalThis.fetch = originalFetchRef.current - setAppDetail(undefined) } - }, [setAppDetail]) + }, []) return ( <>
{ diff --git a/web/app/components/base/agent-log-modal/index.tsx b/web/app/components/base/agent-log-modal/index.tsx index ad5f26cdd1b..2bf88c55e60 100644 --- a/web/app/components/base/agent-log-modal/index.tsx +++ b/web/app/components/base/agent-log-modal/index.tsx @@ -9,12 +9,19 @@ import { useTranslation } from 'react-i18next' import AgentLogDetail from './detail' type AgentLogModalProps = Readonly<{ + appId: string currentLogItem?: IChatItem width: number floating?: boolean onCancel: () => void }> -const AgentLogModal: FC = ({ currentLogItem, width, floating, onCancel }) => { +const AgentLogModal: FC = ({ + appId, + currentLogItem, + width, + floating, + onCancel, +}) => { const { t } = useTranslation(['appLog', 'common']) const ref = useRef(null) const [mounted, setMounted] = useState(false) @@ -32,6 +39,7 @@ const AgentLogModal: FC = ({ currentLogItem, width, floating const detailContent = ( <> void) | null = null @@ -10,10 +9,6 @@ vi.mock('ahooks', () => ({ }, })) -vi.mock('@/app/components/app/store', () => ({ - useStore: vi.fn(), -})) - vi.mock('@/app/components/workflow/run', () => ({ default: ({ activeTab, @@ -46,23 +41,20 @@ describe('MessageLogModal', () => { beforeEach(() => { vi.clearAllMocks() clickAwayHandler = null - // oxlint-disable-next-line typescript/no-explicit-any - vi.mocked(useStore).mockImplementation((selector: any) => - selector({ - appDetail: { id: 'app-1' }, - }), - ) }) describe('Render', () => { it('renders nothing if currentLogItem is missing', () => { - const { container } = render() + const { container } = render( + , + ) expect(container.firstChild).toBeNull() }) it('renders nothing if currentLogItem.workflow_run_id is missing', () => { const { container } = render( { }) it('renders modal with correct title and Run component', () => { - render() + render( + , + ) expect(screen.getByText(/title/i))!.toBeInTheDocument() expect(screen.getByTestId('workflow-run'))!.toBeInTheDocument() }) @@ -82,6 +76,7 @@ describe('MessageLogModal', () => { it('passes correct props to Run component', () => { render( { const runComponent = screen.getByTestId('workflow-run') expect(runComponent.getAttribute('data-active-tab')).toBe('TRACING') expect(runComponent.getAttribute('data-run-detail-url')).toBe( - '/apps/app-1/workflow-runs/run-1', + '/apps/inspected-app/workflow-runs/run-1', ) expect(runComponent.getAttribute('data-tracing-list-url')).toBe( - '/apps/app-1/workflow-runs/run-1/node-executions', + '/apps/inspected-app/workflow-runs/run-1/node-executions', ) }) it('sets fixed style when fixedWidth is false (floating)', () => { const { container } = render( { it('sets fixed width when fixedWidth is true', () => { const { container } = render( { describe('Interaction', () => { it('calls onCancel when close icon is clicked', () => { - render() + render( + , + ) const closeButton = screen.getByRole('button', { name: 'common.operation.close' }) expect(closeButton)!.toBeInTheDocument() fireEvent.click(closeButton) @@ -138,7 +137,13 @@ describe('MessageLogModal', () => { it('calls onCancel when clicked away', () => { render( - , + , ) expect(clickAwayHandler).toBeTruthy() clickAwayHandler!() @@ -146,7 +151,9 @@ describe('MessageLogModal', () => { }) it('does not use click away to close the floating dialog', () => { - render() + render( + , + ) expect(clickAwayHandler).toBeTruthy() clickAwayHandler!() expect(onCancel).not.toHaveBeenCalled() diff --git a/web/app/components/base/message-log-modal/index.stories.tsx b/web/app/components/base/message-log-modal/index.stories.tsx index 04960dc1b1f..fc469231a1c 100644 --- a/web/app/components/base/message-log-modal/index.stories.tsx +++ b/web/app/components/base/message-log-modal/index.stories.tsx @@ -3,17 +3,10 @@ import type { IChatItem } from '@/app/components/base/chat/chat/type' import type { WorkflowRunDetailResponse } from '@/models/log' import type { NodeTracing, NodeTracingListResponse } from '@/types/workflow' import { useEffect } from 'react' -import { useStore } from '@/app/components/app/store' import { WorkflowContextProvider } from '@/app/components/workflow/context' import { BlockEnum } from '@/app/components/workflow/types' import MessageLogModal from '.' -const SAMPLE_APP_DETAIL = { - id: 'app-demo-1', - name: 'Support Assistant', - mode: 'chat', -} as any - const mockRunDetail: WorkflowRunDetailResponse = { id: 'run-demo-1', version: 'v1.0.0', @@ -96,9 +89,6 @@ const mockCurrentLogItem: IChatItem = { const useMessageLogMocks = () => { useEffect(() => { - const store = useStore.getState() - store.setAppDetail(SAMPLE_APP_DETAIL) - const originalFetch = globalThis.fetch?.bind(globalThis) ?? null const handle = async (input: RequestInfo | URL, init?: RequestInit) => { @@ -128,7 +118,6 @@ const useMessageLogMocks = () => { return () => { globalThis.fetch = originalFetch || globalThis.fetch - useStore.getState().setAppDetail(undefined) } }, []) } @@ -160,6 +149,7 @@ const meta = { }, }, args: { + appId: 'app-demo-1', defaultTab: 'DETAIL', width: 960, fixedWidth: true, diff --git a/web/app/components/base/message-log-modal/index.tsx b/web/app/components/base/message-log-modal/index.tsx index ffab535f636..b5e05728def 100644 --- a/web/app/components/base/message-log-modal/index.tsx +++ b/web/app/components/base/message-log-modal/index.tsx @@ -5,7 +5,6 @@ import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { useClickAway } from 'ahooks' import { useRef } from 'react' import { useTranslation } from 'react-i18next' -import { useStore } from '@/app/components/app/store' import Run from '@/app/components/workflow/run' type RunActiveTab = 'RESULT' | 'DETAIL' | 'TRACING' @@ -14,6 +13,7 @@ const isRunActiveTab = (tab: string): tab is RunActiveTab => tab === 'RESULT' || tab === 'DETAIL' || tab === 'TRACING' type MessageLogModalProps = { + appId: string currentLogItem?: IChatItem defaultTab?: string width: number @@ -21,6 +21,7 @@ type MessageLogModalProps = { onCancel: () => void } const MessageLogModal: FC = ({ + appId, currentLogItem, defaultTab = 'DETAIL', width, @@ -29,7 +30,6 @@ const MessageLogModal: FC = ({ }) => { const { t } = useTranslation(['appLog', 'common']) const ref = useRef(null) - const appDetail = useStore((state) => state.appDetail) useClickAway(() => { if (fixedWidth) onCancel() @@ -54,8 +54,8 @@ const MessageLogModal: FC = ({ ) @@ -104,8 +104,8 @@ const MessageLogModal: FC = ({
) diff --git a/web/app/components/workflow-app/components/workflow-panel.tsx b/web/app/components/workflow-app/components/workflow-panel.tsx index f12c0ebb9e4..e266f67d3fc 100644 --- a/web/app/components/workflow-app/components/workflow-panel.tsx +++ b/web/app/components/workflow-app/components/workflow-panel.tsx @@ -35,13 +35,14 @@ const GlobalVariablePanel = dynamic( }, ) -const WorkflowPanelOnLeft = () => { +const WorkflowPanelOnLeft = ({ appId }: { appId: string }) => { const messageLogItem = useStore((state) => state.messageLogItem) const setMessageLogItem = useStore((state) => state.setMessageLogItem) return ( <> {messageLogItem && ( { const panelProps: PanelProps = useMemo(() => { return { components: { - left: , + left: appDetail && , right: , }, versionHistoryPanelProps, } - }, [versionHistoryPanelProps]) + }, [appDetail, versionHistoryPanelProps]) return }