mirror of
https://github.com/langgenius/dify.git
synced 2026-04-05 20:22:39 +08:00
feat(web): test run of snippet
This commit is contained in:
@@ -15,6 +15,13 @@ const mockToggleInputPanel = vi.fn()
|
||||
const mockTogglePublishMenu = vi.fn()
|
||||
const mockPublishSnippetMutateAsync = vi.fn()
|
||||
const mockFetchInspectVars = vi.fn()
|
||||
const mockHandleBackupDraft = vi.fn()
|
||||
const mockHandleLoadBackupDraft = vi.fn()
|
||||
const mockHandleRestoreFromPublishedWorkflow = vi.fn()
|
||||
const mockHandleRun = vi.fn()
|
||||
const mockHandleStartWorkflowRun = vi.fn()
|
||||
const mockHandleStopRun = vi.fn()
|
||||
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
|
||||
const mockInspectVarsCrud = {
|
||||
hasNodeInspectVars: vi.fn(),
|
||||
hasSetInspectVar: vi.fn(),
|
||||
@@ -111,6 +118,23 @@ vi.mock('@/app/components/snippets/hooks/use-snippet-refresh-draft', () => ({
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-snippet-run', () => ({
|
||||
useSnippetRun: () => ({
|
||||
handleBackupDraft: mockHandleBackupDraft,
|
||||
handleLoadBackupDraft: mockHandleLoadBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow: mockHandleRestoreFromPublishedWorkflow,
|
||||
handleRun: mockHandleRun,
|
||||
handleStopRun: mockHandleStopRun,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/snippets/hooks/use-snippet-start-run', () => ({
|
||||
useSnippetStartRun: () => ({
|
||||
handleStartWorkflowRun: mockHandleStartWorkflowRun,
|
||||
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app-sidebar', () => ({
|
||||
default: ({
|
||||
renderHeader,
|
||||
@@ -304,4 +328,18 @@ describe('SnippetMain', () => {
|
||||
expect(capturedHooksStore?.invalidateConversationVarValues).toBe(mockInspectVarsCrud.invalidateConversationVarValues)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Run Hooks', () => {
|
||||
it('should pass snippet run handlers to WorkflowWithInnerContext', () => {
|
||||
renderSnippetMain()
|
||||
|
||||
expect(capturedHooksStore?.handleBackupDraft).toBe(mockHandleBackupDraft)
|
||||
expect(capturedHooksStore?.handleLoadBackupDraft).toBe(mockHandleLoadBackupDraft)
|
||||
expect(capturedHooksStore?.handleRestoreFromPublishedWorkflow).toBe(mockHandleRestoreFromPublishedWorkflow)
|
||||
expect(capturedHooksStore?.handleRun).toBe(mockHandleRun)
|
||||
expect(capturedHooksStore?.handleStopRun).toBe(mockHandleStopRun)
|
||||
expect(capturedHooksStore?.handleStartWorkflowRun).toBe(mockHandleStartWorkflowRun)
|
||||
expect(capturedHooksStore?.handleWorkflowStartRunInWorkflow).toBe(mockHandleWorkflowStartRunInWorkflow)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -45,6 +45,7 @@ describe('SnippetWorkflowPanel', () => {
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.restoreVersionUrl('version-1')).toBe('/snippets/snippet-1/workflows/version-1/restore')
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.updateVersionUrl?.('version-1')).toBe('/snippets/snippet-1/workflows/version-1')
|
||||
expect(capturedPanelProps?.versionHistoryPanelProps?.latestVersionId).toBe('')
|
||||
expect(capturedPanelProps?.components?.right).toBeTruthy()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,8 @@ import { useConfigsMap } from '../hooks/use-configs-map'
|
||||
import { useInspectVarsCrud } from '../hooks/use-inspect-vars-crud'
|
||||
import { useNodesSyncDraft } from '../hooks/use-nodes-sync-draft'
|
||||
import { useSnippetRefreshDraft } from '../hooks/use-snippet-refresh-draft'
|
||||
import { useSnippetRun } from '../hooks/use-snippet-run'
|
||||
import { useSnippetStartRun } from '../hooks/use-snippet-start-run'
|
||||
import { useSnippetDetailStore } from '../store'
|
||||
import { useSnippetInputFieldActions } from './hooks/use-snippet-input-field-actions'
|
||||
import { useSnippetPublish } from './hooks/use-snippet-publish'
|
||||
@@ -66,6 +68,13 @@ const SnippetMain = ({
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
} = useNodesSyncDraft(snippetId)
|
||||
const { handleRefreshWorkflowDraft } = useSnippetRefreshDraft(snippetId)
|
||||
const {
|
||||
handleBackupDraft,
|
||||
handleLoadBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow,
|
||||
handleRun,
|
||||
handleStopRun,
|
||||
} = useSnippetRun(snippetId)
|
||||
const configsMap = useConfigsMap(snippetId)
|
||||
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
|
||||
...configsMap,
|
||||
@@ -132,6 +141,13 @@ const SnippetMain = ({
|
||||
snippetId,
|
||||
section,
|
||||
})
|
||||
const {
|
||||
handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
} = useSnippetStartRun({
|
||||
handleRun,
|
||||
inputFields: fields,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
reset()
|
||||
@@ -148,6 +164,13 @@ const SnippetMain = ({
|
||||
doSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
handleRefreshWorkflowDraft,
|
||||
handleBackupDraft,
|
||||
handleLoadBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow,
|
||||
handleRun,
|
||||
handleStopRun,
|
||||
handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
availableNodesMetaData,
|
||||
fetchInspectVars,
|
||||
hasNodeInspectVars,
|
||||
@@ -177,7 +200,14 @@ const SnippetMain = ({
|
||||
editInspectVarValue,
|
||||
fetchInspectVarValue,
|
||||
fetchInspectVars,
|
||||
handleBackupDraft,
|
||||
handleRefreshWorkflowDraft,
|
||||
handleLoadBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow,
|
||||
handleRun,
|
||||
handleStartWorkflowRun,
|
||||
handleStopRun,
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
hasNodeInspectVars,
|
||||
hasSetInspectVar,
|
||||
invalidateConversationVarValues,
|
||||
|
||||
293
web/app/components/snippets/components/snippet-run-panel.tsx
Normal file
293
web/app/components/snippets/components/snippet-run-panel.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
'use client'
|
||||
|
||||
import type { InputForm } from '@/app/components/base/chat/chat/type'
|
||||
import type { InputVar as WorkflowInputVar } from '@/app/components/workflow/types'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useCheckInputsForms } from '@/app/components/base/chat/chat/check-input-forms-hooks'
|
||||
import { getProcessedInputs } from '@/app/components/base/chat/chat/utils'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import {
|
||||
useWorkflowInteractions,
|
||||
useWorkflowRun,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import FormItem from '@/app/components/workflow/nodes/_base/components/before-run-form/form-item'
|
||||
import ResultPanel from '@/app/components/workflow/run/result-panel'
|
||||
import ResultText from '@/app/components/workflow/run/result-text'
|
||||
import TracingPanel from '@/app/components/workflow/run/tracing-panel'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import {
|
||||
InputVarType,
|
||||
WorkflowRunningStatus,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { formatWorkflowRunIdentifier } from '@/app/components/workflow/utils'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
|
||||
type SnippetRunPanelProps = {
|
||||
fields: SnippetInputField[]
|
||||
}
|
||||
|
||||
type SnippetRunField = WorkflowInputVar & InputForm
|
||||
|
||||
const PIPELINE_TO_WORKFLOW_INPUT_VAR_TYPE: Record<PipelineInputVarType, InputVarType> = {
|
||||
[PipelineInputVarType.textInput]: InputVarType.textInput,
|
||||
[PipelineInputVarType.paragraph]: InputVarType.paragraph,
|
||||
[PipelineInputVarType.select]: InputVarType.select,
|
||||
[PipelineInputVarType.number]: InputVarType.number,
|
||||
[PipelineInputVarType.singleFile]: InputVarType.singleFile,
|
||||
[PipelineInputVarType.multiFiles]: InputVarType.multiFiles,
|
||||
[PipelineInputVarType.checkbox]: InputVarType.checkbox,
|
||||
}
|
||||
|
||||
const buildPreviewFields = (fields: SnippetInputField[]): SnippetRunField[] => {
|
||||
return fields.map(field => ({
|
||||
type: PIPELINE_TO_WORKFLOW_INPUT_VAR_TYPE[field.type],
|
||||
label: field.label,
|
||||
variable: field.variable,
|
||||
max_length: field.max_length,
|
||||
default: field.default_value,
|
||||
required: field.required,
|
||||
options: field.options,
|
||||
placeholder: field.placeholder,
|
||||
unit: field.unit,
|
||||
hide: false,
|
||||
allowed_file_upload_methods: field.allowed_file_upload_methods,
|
||||
allowed_file_types: field.allowed_file_types,
|
||||
allowed_file_extensions: field.allowed_file_extensions,
|
||||
}))
|
||||
}
|
||||
|
||||
const buildInitialInputs = (fields: SnippetRunField[]) => {
|
||||
return fields.reduce<Record<string, unknown>>((acc, field) => {
|
||||
if (field.default !== undefined)
|
||||
acc[field.variable] = field.default
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const SnippetRunPanel = ({
|
||||
fields,
|
||||
}: SnippetRunPanelProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
|
||||
const { handleRun } = useWorkflowRun()
|
||||
const { checkInputsForm } = useCheckInputsForms()
|
||||
const workflowRunningData = useStore(s => s.workflowRunningData)
|
||||
const showInputsPanel = useStore(s => s.showInputsPanel)
|
||||
const workflowCanvasWidth = useStore(s => s.workflowCanvasWidth)
|
||||
const panelWidth = useStore(s => s.previewPanelWidth)
|
||||
const setPreviewPanelWidth = useStore(s => s.setPreviewPanelWidth)
|
||||
|
||||
const previewFields = useMemo(() => buildPreviewFields(fields), [fields])
|
||||
const initialInputs = useMemo(() => buildInitialInputs(previewFields), [previewFields])
|
||||
const [inputOverrides, setInputOverrides] = useState<Record<string, unknown> | null>(null)
|
||||
const [selectedTab, setSelectedTab] = useState<string | null>(null)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
|
||||
const inputs = inputOverrides ?? initialInputs
|
||||
const hasInputTab = showInputsPanel && previewFields.length > 0
|
||||
const defaultTab = hasInputTab ? 'INPUT' : 'RESULT'
|
||||
const shouldShowDetailByDefault = !!workflowRunningData
|
||||
&& (workflowRunningData.result.status === WorkflowRunningStatus.Succeeded || workflowRunningData.result.status === WorkflowRunningStatus.Failed)
|
||||
&& !workflowRunningData.resultText
|
||||
&& !workflowRunningData.result.files?.length
|
||||
const currentTab = selectedTab ?? (shouldShowDetailByDefault ? 'DETAIL' : defaultTab)
|
||||
|
||||
const handleValueChange = useCallback((variable: string, value: unknown) => {
|
||||
setInputOverrides(prev => ({
|
||||
...(prev ?? initialInputs),
|
||||
[variable]: value,
|
||||
}))
|
||||
}, [initialInputs])
|
||||
|
||||
const handleSubmit = useCallback(() => {
|
||||
if (!checkInputsForm(inputs, previewFields))
|
||||
return
|
||||
|
||||
setSelectedTab('RESULT')
|
||||
handleRun({
|
||||
inputs: getProcessedInputs(inputs, previewFields),
|
||||
})
|
||||
}, [checkInputsForm, handleRun, inputs, previewFields])
|
||||
|
||||
const startResizing = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setIsResizing(true)
|
||||
}, [])
|
||||
|
||||
const stopResizing = useCallback(() => {
|
||||
setIsResizing(false)
|
||||
}, [])
|
||||
|
||||
const resize = useCallback((e: MouseEvent) => {
|
||||
if (!isResizing)
|
||||
return
|
||||
|
||||
const newWidth = window.innerWidth - e.clientX
|
||||
const reservedCanvasWidth = 400
|
||||
const maxAllowed = workflowCanvasWidth ? (workflowCanvasWidth - reservedCanvasWidth) : 1024
|
||||
|
||||
if (newWidth >= 400 && newWidth <= maxAllowed)
|
||||
setPreviewPanelWidth(newWidth)
|
||||
}, [isResizing, setPreviewPanelWidth, workflowCanvasWidth])
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('mousemove', resize)
|
||||
window.addEventListener('mouseup', stopResizing)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', resize)
|
||||
window.removeEventListener('mouseup', stopResizing)
|
||||
}
|
||||
}, [resize, stopResizing])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative flex h-full flex-col rounded-l-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl"
|
||||
style={{ width: `${panelWidth}px` }}
|
||||
>
|
||||
<div
|
||||
className="absolute bottom-0 left-[3px] top-1/2 z-50 h-6 w-[3px] cursor-col-resize rounded bg-gray-300"
|
||||
onMouseDown={startResizing}
|
||||
/>
|
||||
<div className="flex items-center justify-between p-4 pb-1 text-base font-semibold text-text-primary">
|
||||
{`Test Run${formatWorkflowRunIdentifier(workflowRunningData?.result.finished_at, workflowRunningData?.result.status)}`}
|
||||
<div className="cursor-pointer p-1" onClick={handleCancelDebugAndPreviewPanel}>
|
||||
<span className="i-ri-close-line h-4 w-4 text-text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex grow flex-col">
|
||||
<div className="flex shrink-0 items-center border-b-[0.5px] border-divider-subtle px-4">
|
||||
{hasInputTab && (
|
||||
<div
|
||||
className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] font-semibold leading-[18px] ${currentTab === 'INPUT' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'}`}
|
||||
onClick={() => setSelectedTab('INPUT')}
|
||||
>
|
||||
{t('input', { ns: 'runLog' })}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] font-semibold leading-[18px] ${currentTab === 'RESULT' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'} ${!workflowRunningData ? '!cursor-not-allowed opacity-30' : ''}`}
|
||||
onClick={() => workflowRunningData && setSelectedTab('RESULT')}
|
||||
>
|
||||
{t('result', { ns: 'runLog' })}
|
||||
</div>
|
||||
<div
|
||||
className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] font-semibold leading-[18px] ${currentTab === 'DETAIL' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'} ${!workflowRunningData ? '!cursor-not-allowed opacity-30' : ''}`}
|
||||
onClick={() => workflowRunningData && setSelectedTab('DETAIL')}
|
||||
>
|
||||
{t('detail', { ns: 'runLog' })}
|
||||
</div>
|
||||
<div
|
||||
className={`mr-6 cursor-pointer border-b-2 py-3 text-[13px] font-semibold leading-[18px] ${currentTab === 'TRACING' ? '!border-[rgb(21,94,239)] text-text-secondary' : 'border-transparent text-text-tertiary'} ${!workflowRunningData ? '!cursor-not-allowed opacity-30' : ''}`}
|
||||
onClick={() => workflowRunningData && setSelectedTab('TRACING')}
|
||||
>
|
||||
{t('tracing', { ns: 'runLog' })}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`h-0 grow overflow-y-auto rounded-b-2xl ${(currentTab === 'RESULT' || currentTab === 'TRACING') ? '!bg-background-section-burn' : 'bg-components-panel-bg'}`}>
|
||||
{currentTab === 'INPUT' && hasInputTab && (
|
||||
<>
|
||||
<div className="px-4 pb-2 pt-3">
|
||||
{previewFields.map((field, index) => (
|
||||
<div
|
||||
key={field.variable}
|
||||
className="mb-2 last-of-type:mb-0"
|
||||
>
|
||||
<FormItem
|
||||
autoFocus={index === 0}
|
||||
className="!block"
|
||||
payload={field}
|
||||
value={inputs[field.variable]}
|
||||
onChange={value => handleValueChange(field.variable, value)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-between px-4 py-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
disabled={workflowRunningData?.result?.status === WorkflowRunningStatus.Running}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t('singleRun.startRun', { ns: 'workflow' })}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{currentTab === 'RESULT' && (
|
||||
<div className="p-2">
|
||||
<ResultText
|
||||
isRunning={workflowRunningData?.result?.status === WorkflowRunningStatus.Running || !workflowRunningData?.result}
|
||||
outputs={workflowRunningData?.resultText}
|
||||
allFiles={workflowRunningData?.result?.files}
|
||||
error={workflowRunningData?.result?.error}
|
||||
onClick={() => setSelectedTab('DETAIL')}
|
||||
/>
|
||||
{(workflowRunningData?.result.status === WorkflowRunningStatus.Succeeded && workflowRunningData?.resultText && typeof workflowRunningData.resultText === 'string') && (
|
||||
<Button
|
||||
className="mb-4 ml-4 space-x-1"
|
||||
onClick={() => {
|
||||
copy(workflowRunningData?.resultText || '')
|
||||
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
|
||||
}}
|
||||
>
|
||||
<span className="i-ri-clipboard-line h-3.5 w-3.5" />
|
||||
<div>{t('operation.copy', { ns: 'common' })}</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{currentTab === 'DETAIL' && workflowRunningData?.result && (
|
||||
<ResultPanel
|
||||
inputs={workflowRunningData.result?.inputs}
|
||||
inputs_truncated={workflowRunningData.result?.inputs_truncated}
|
||||
process_data={workflowRunningData.result?.process_data}
|
||||
process_data_truncated={workflowRunningData.result?.process_data_truncated}
|
||||
outputs={workflowRunningData.result?.outputs}
|
||||
outputs_truncated={workflowRunningData.result?.outputs_truncated}
|
||||
outputs_full_content={workflowRunningData.result?.outputs_full_content}
|
||||
status={workflowRunningData.result?.status || ''}
|
||||
error={workflowRunningData.result?.error}
|
||||
elapsed_time={workflowRunningData.result?.elapsed_time}
|
||||
total_tokens={workflowRunningData.result?.total_tokens}
|
||||
created_at={workflowRunningData.result?.created_at}
|
||||
created_by={workflowRunningData.result?.created_by}
|
||||
steps={workflowRunningData.result?.total_steps}
|
||||
exceptionCounts={workflowRunningData.result?.exceptions_count}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'DETAIL' && !workflowRunningData?.result && (
|
||||
<div className="flex h-full items-center justify-center bg-components-panel-bg">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
{currentTab === 'TRACING' && (
|
||||
<TracingPanel
|
||||
className="bg-background-section-burn"
|
||||
list={workflowRunningData?.tracing || []}
|
||||
/>
|
||||
)}
|
||||
{currentTab === 'TRACING' && !workflowRunningData?.tracing?.length && (
|
||||
<div className="flex h-full items-center justify-center !bg-background-section-burn">
|
||||
<Loading />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(SnippetRunPanel)
|
||||
@@ -4,9 +4,18 @@ import type { PanelProps } from '@/app/components/workflow/panel'
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { memo, useMemo } from 'react'
|
||||
import Panel from '@/app/components/workflow/panel'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import SnippetInputFieldEditor from './input-field-editor'
|
||||
import SnippetInputFieldPanel from './panel'
|
||||
|
||||
const Record = dynamic(() => import('@/app/components/workflow/panel/record'), {
|
||||
ssr: false,
|
||||
})
|
||||
const SnippetRunPanel = dynamic(() => import('./snippet-run-panel'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
type SnippetWorkflowPanelProps = {
|
||||
snippetId: string
|
||||
fields: SnippetInputField[]
|
||||
@@ -56,6 +65,20 @@ const SnippetPanelOnLeft = ({
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetPanelOnRight = ({
|
||||
fields,
|
||||
}: Pick<SnippetWorkflowPanelProps, 'fields'>) => {
|
||||
const historyWorkflowData = useStore(s => s.historyWorkflowData)
|
||||
const showDebugAndPreviewPanel = useStore(s => s.showDebugAndPreviewPanel)
|
||||
|
||||
return (
|
||||
<>
|
||||
{historyWorkflowData && <Record />}
|
||||
{showDebugAndPreviewPanel && <SnippetRunPanel fields={fields} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const SnippetWorkflowPanel = ({
|
||||
snippetId,
|
||||
fields,
|
||||
@@ -97,6 +120,7 @@ const SnippetWorkflowPanel = ({
|
||||
onSortChange={onSortChange}
|
||||
/>
|
||||
),
|
||||
right: <SnippetPanelOnRight fields={fields} />,
|
||||
},
|
||||
versionHistoryPanelProps,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { act } from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useSnippetStartRun } from '../use-snippet-start-run'
|
||||
|
||||
const mockWorkflowStoreGetState = vi.fn()
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: mockWorkflowStoreGetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowInteractions: () => ({
|
||||
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
const mockSetShowDebugAndPreviewPanel = vi.fn()
|
||||
const mockSetShowInputsPanel = vi.fn()
|
||||
const mockSetShowEnvPanel = vi.fn()
|
||||
const mockSetShowGlobalVariablePanel = vi.fn()
|
||||
const mockHandleRun = vi.fn()
|
||||
|
||||
const inputFields: SnippetInputField[] = [
|
||||
{
|
||||
type: PipelineInputVarType.textInput,
|
||||
label: 'Query',
|
||||
variable: 'query',
|
||||
required: true,
|
||||
},
|
||||
]
|
||||
|
||||
describe('useSnippetStartRun', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
workflowRunningData: undefined,
|
||||
showDebugAndPreviewPanel: false,
|
||||
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
|
||||
setShowInputsPanel: mockSetShowInputsPanel,
|
||||
setShowEnvPanel: mockSetShowEnvPanel,
|
||||
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
|
||||
})
|
||||
})
|
||||
|
||||
it('should open the debug panel and input form when snippet has input fields', () => {
|
||||
const { result } = renderHook(() => useSnippetStartRun({
|
||||
handleRun: mockHandleRun,
|
||||
inputFields,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should run immediately when snippet has no input fields', () => {
|
||||
const { result } = renderHook(() => useSnippetStartRun({
|
||||
handleRun: mockHandleRun,
|
||||
inputFields: [],
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
|
||||
expect(mockHandleRun).toHaveBeenCalledWith({ inputs: {} })
|
||||
})
|
||||
|
||||
it('should close the panel when debug panel is already open', () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
workflowRunningData: undefined,
|
||||
showDebugAndPreviewPanel: true,
|
||||
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
|
||||
setShowInputsPanel: mockSetShowInputsPanel,
|
||||
setShowEnvPanel: mockSetShowEnvPanel,
|
||||
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSnippetStartRun({
|
||||
handleRun: mockHandleRun,
|
||||
inputFields,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should do nothing when workflow is already running', () => {
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
workflowRunningData: {
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
},
|
||||
},
|
||||
showDebugAndPreviewPanel: false,
|
||||
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
|
||||
setShowInputsPanel: mockSetShowInputsPanel,
|
||||
setShowEnvPanel: mockSetShowEnvPanel,
|
||||
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useSnippetStartRun({
|
||||
handleRun: mockHandleRun,
|
||||
inputFields,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
298
web/app/components/snippets/hooks/use-snippet-run.ts
Normal file
298
web/app/components/snippets/hooks/use-snippet-run.ts
Normal file
@@ -0,0 +1,298 @@
|
||||
import type { IOtherOptions } from '@/service/base'
|
||||
import type { SnippetDraftRunPayload } from '@/types/snippet'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import {
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import { useSetWorkflowVarsWithValue } from '@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars'
|
||||
import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions'
|
||||
import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { ssePost } from '@/service/base'
|
||||
import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow'
|
||||
import { stopWorkflowRun } from '@/service/workflow'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
|
||||
export const useSnippetRun = (snippetId: string) => {
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const reactflow = useReactFlow()
|
||||
const { doSyncWorkflowDraft } = useNodesSyncDraft(snippetId)
|
||||
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
|
||||
|
||||
const {
|
||||
handleWorkflowStarted,
|
||||
handleWorkflowFinished,
|
||||
handleWorkflowFailed,
|
||||
handleWorkflowNodeStarted,
|
||||
handleWorkflowNodeFinished,
|
||||
handleWorkflowNodeIterationStarted,
|
||||
handleWorkflowNodeIterationNext,
|
||||
handleWorkflowNodeIterationFinished,
|
||||
handleWorkflowNodeLoopStarted,
|
||||
handleWorkflowNodeLoopNext,
|
||||
handleWorkflowNodeLoopFinished,
|
||||
handleWorkflowNodeRetry,
|
||||
handleWorkflowAgentLog,
|
||||
handleWorkflowTextChunk,
|
||||
handleWorkflowTextReplace,
|
||||
} = useWorkflowRunEvent()
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const handleBackupDraft = useCallback(() => {
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
} = store.getState()
|
||||
const { getViewport } = reactflow
|
||||
const {
|
||||
backupDraft,
|
||||
setBackupDraft,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (!backupDraft) {
|
||||
setBackupDraft({
|
||||
nodes: getNodes(),
|
||||
edges,
|
||||
viewport: getViewport(),
|
||||
environmentVariables: [],
|
||||
})
|
||||
doSyncWorkflowDraft()
|
||||
}
|
||||
}, [doSyncWorkflowDraft, reactflow, store, workflowStore])
|
||||
|
||||
const handleLoadBackupDraft = useCallback(() => {
|
||||
const {
|
||||
backupDraft,
|
||||
setBackupDraft,
|
||||
setEnvironmentVariables,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (backupDraft) {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
} = backupDraft
|
||||
handleUpdateWorkflowCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
setEnvironmentVariables([])
|
||||
setBackupDraft(undefined)
|
||||
}
|
||||
}, [handleUpdateWorkflowCanvas, workflowStore])
|
||||
|
||||
const invalidAllLastRun = useInvalidAllLastRun(FlowType.snippet, snippetId)
|
||||
const invalidateRunHistory = useInvalidateWorkflowRunHistory()
|
||||
const { fetchInspectVars } = useSetWorkflowVarsWithValue({
|
||||
flowType: FlowType.snippet,
|
||||
flowId: snippetId,
|
||||
})
|
||||
|
||||
const handleRun = useCallback(async (
|
||||
params: SnippetDraftRunPayload,
|
||||
callback?: IOtherOptions,
|
||||
) => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const newNodes = produce(getNodes(), (draft) => {
|
||||
draft.forEach((node) => {
|
||||
node.data.selected = false
|
||||
node.data._runningStatus = undefined
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
await doSyncWorkflowDraft()
|
||||
|
||||
const {
|
||||
onWorkflowStarted,
|
||||
onWorkflowFinished,
|
||||
onNodeStarted,
|
||||
onNodeFinished,
|
||||
onIterationStart,
|
||||
onIterationNext,
|
||||
onIterationFinish,
|
||||
onLoopStart,
|
||||
onLoopNext,
|
||||
onLoopFinish,
|
||||
onNodeRetry,
|
||||
onAgentLog,
|
||||
onError,
|
||||
...restCallback
|
||||
} = callback || {}
|
||||
const runHistoryUrl = `/snippets/${snippetId}/workflow-runs`
|
||||
workflowStore.setState({ historyWorkflowData: undefined })
|
||||
const workflowContainer = document.getElementById('workflow-container')
|
||||
|
||||
const {
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
} = workflowContainer!
|
||||
|
||||
const url = `/snippets/${snippetId}/workflows/draft/run`
|
||||
|
||||
const {
|
||||
setWorkflowRunningData,
|
||||
} = workflowStore.getState()
|
||||
setWorkflowRunningData({
|
||||
result: {
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
status: WorkflowRunningStatus.Running,
|
||||
},
|
||||
tracing: [],
|
||||
resultText: '',
|
||||
})
|
||||
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = null
|
||||
|
||||
ssePost(
|
||||
url,
|
||||
{
|
||||
body: params,
|
||||
},
|
||||
{
|
||||
getAbortController: (controller: AbortController) => {
|
||||
abortControllerRef.current = controller
|
||||
},
|
||||
onWorkflowStarted: (params) => {
|
||||
handleWorkflowStarted(params)
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
|
||||
onWorkflowStarted?.(params)
|
||||
},
|
||||
onWorkflowFinished: (params) => {
|
||||
handleWorkflowFinished(params)
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
fetchInspectVars({})
|
||||
invalidAllLastRun()
|
||||
|
||||
onWorkflowFinished?.(params)
|
||||
},
|
||||
onError: (params) => {
|
||||
handleWorkflowFailed()
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
|
||||
onError?.(params)
|
||||
},
|
||||
onNodeStarted: (params) => {
|
||||
handleWorkflowNodeStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
onNodeStarted?.(params)
|
||||
},
|
||||
onNodeFinished: (params) => {
|
||||
handleWorkflowNodeFinished(params)
|
||||
|
||||
onNodeFinished?.(params)
|
||||
},
|
||||
onIterationStart: (params) => {
|
||||
handleWorkflowNodeIterationStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
onIterationStart?.(params)
|
||||
},
|
||||
onIterationNext: (params) => {
|
||||
handleWorkflowNodeIterationNext(params)
|
||||
|
||||
onIterationNext?.(params)
|
||||
},
|
||||
onIterationFinish: (params) => {
|
||||
handleWorkflowNodeIterationFinished(params)
|
||||
|
||||
onIterationFinish?.(params)
|
||||
},
|
||||
onLoopStart: (params) => {
|
||||
handleWorkflowNodeLoopStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
onLoopStart?.(params)
|
||||
},
|
||||
onLoopNext: (params) => {
|
||||
handleWorkflowNodeLoopNext(params)
|
||||
|
||||
onLoopNext?.(params)
|
||||
},
|
||||
onLoopFinish: (params) => {
|
||||
handleWorkflowNodeLoopFinished(params)
|
||||
|
||||
onLoopFinish?.(params)
|
||||
},
|
||||
onNodeRetry: (params) => {
|
||||
handleWorkflowNodeRetry(params)
|
||||
|
||||
onNodeRetry?.(params)
|
||||
},
|
||||
onAgentLog: (params) => {
|
||||
handleWorkflowAgentLog(params)
|
||||
|
||||
onAgentLog?.(params)
|
||||
},
|
||||
onTextChunk: (params) => {
|
||||
handleWorkflowTextChunk(params)
|
||||
},
|
||||
onTextReplace: (params) => {
|
||||
handleWorkflowTextReplace(params)
|
||||
},
|
||||
...restCallback,
|
||||
},
|
||||
)
|
||||
}, [doSyncWorkflowDraft, fetchInspectVars, handleWorkflowAgentLog, handleWorkflowFailed, handleWorkflowFinished, handleWorkflowNodeFinished, handleWorkflowNodeIterationFinished, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationStarted, handleWorkflowNodeLoopFinished, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopStarted, handleWorkflowNodeRetry, handleWorkflowNodeStarted, handleWorkflowStarted, handleWorkflowTextChunk, handleWorkflowTextReplace, invalidAllLastRun, invalidateRunHistory, snippetId, store, workflowStore])
|
||||
|
||||
const handleStopRun = useCallback((taskId: string) => {
|
||||
stopWorkflowRun(`/snippets/${snippetId}/workflow-runs/tasks/${taskId}/stop`)
|
||||
|
||||
if (abortControllerRef.current)
|
||||
abortControllerRef.current.abort()
|
||||
|
||||
abortControllerRef.current = null
|
||||
}, [snippetId])
|
||||
|
||||
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
|
||||
const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))
|
||||
const edges = publishedWorkflow.graph.edges
|
||||
const viewport = publishedWorkflow.graph.viewport!
|
||||
handleUpdateWorkflowCanvas({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
|
||||
workflowStore.getState().setEnvironmentVariables([])
|
||||
}, [handleUpdateWorkflowCanvas, workflowStore])
|
||||
|
||||
return {
|
||||
handleBackupDraft,
|
||||
handleLoadBackupDraft,
|
||||
handleRun,
|
||||
handleStopRun,
|
||||
handleRestoreFromPublishedWorkflow,
|
||||
}
|
||||
}
|
||||
60
web/app/components/snippets/hooks/use-snippet-start-run.ts
Normal file
60
web/app/components/snippets/hooks/use-snippet-start-run.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { SnippetInputField } from '@/models/snippet'
|
||||
import type { SnippetDraftRunPayload } from '@/types/snippet'
|
||||
import { useCallback } from 'react'
|
||||
import { useWorkflowInteractions } from '@/app/components/workflow/hooks'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
|
||||
type UseSnippetStartRunOptions = {
|
||||
handleRun: (params: SnippetDraftRunPayload) => void
|
||||
inputFields: SnippetInputField[]
|
||||
}
|
||||
|
||||
export const useSnippetStartRun = ({
|
||||
handleRun,
|
||||
inputFields,
|
||||
}: UseSnippetStartRunOptions) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleCancelDebugAndPreviewPanel } = useWorkflowInteractions()
|
||||
|
||||
const handleWorkflowStartRunInWorkflow = useCallback(() => {
|
||||
const {
|
||||
workflowRunningData,
|
||||
showDebugAndPreviewPanel,
|
||||
setShowDebugAndPreviewPanel,
|
||||
setShowInputsPanel,
|
||||
setShowEnvPanel,
|
||||
setShowGlobalVariablePanel,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (workflowRunningData?.result.status === WorkflowRunningStatus.Running)
|
||||
return
|
||||
|
||||
setShowEnvPanel(false)
|
||||
setShowGlobalVariablePanel(false)
|
||||
|
||||
if (showDebugAndPreviewPanel) {
|
||||
handleCancelDebugAndPreviewPanel()
|
||||
return
|
||||
}
|
||||
|
||||
setShowDebugAndPreviewPanel(true)
|
||||
|
||||
if (inputFields.length > 0) {
|
||||
setShowInputsPanel(true)
|
||||
return
|
||||
}
|
||||
|
||||
setShowInputsPanel(false)
|
||||
handleRun({ inputs: {} })
|
||||
}, [handleCancelDebugAndPreviewPanel, handleRun, inputFields.length, workflowStore])
|
||||
|
||||
const handleStartWorkflowRun = useCallback(() => {
|
||||
handleWorkflowStartRunInWorkflow()
|
||||
}, [handleWorkflowStartRunInWorkflow])
|
||||
|
||||
return {
|
||||
handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user