This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,38 @@
import { memo } from 'react'
import Button from '@/app/components/base/button'
import { BubbleX } from '@/app/components/base/icons/src/vender/line/others'
import { useStore } from '@/app/components/workflow/store'
import useTheme from '@/hooks/use-theme'
import cn from '@/utils/classnames'
const ChatVariableButton = ({ disabled }: { disabled: boolean }) => {
const { theme } = useTheme()
const showChatVariablePanel = useStore(s => s.showChatVariablePanel)
const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel)
const setShowEnvPanel = useStore(s => s.setShowEnvPanel)
const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel)
const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel)
const handleClick = () => {
setShowChatVariablePanel(true)
setShowEnvPanel(false)
setShowGlobalVariablePanel(false)
setShowDebugAndPreviewPanel(false)
}
return (
<Button
className={cn(
'p-2',
theme === 'dark' && showChatVariablePanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
)}
disabled={disabled}
onClick={handleClick}
variant='ghost'
>
<BubbleX className='h-4 w-4 text-components-button-secondary-text' />
</Button>
)
}
export default memo(ChatVariableButton)

View File

@@ -0,0 +1,192 @@
import {
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
useEdges,
} from 'reactflow'
import {
RiCloseLine,
RiListCheck3,
} from '@remixicon/react'
import BlockIcon from '../block-icon'
import {
useChecklist,
useNodesInteractions,
} from '../hooks'
import type { ChecklistItem } from '../hooks/use-checklist'
import type {
CommonEdgeType,
} from '../types'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import {
ChecklistSquare,
} from '@/app/components/base/icons/src/vender/line/general'
import { Warning } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import { IconR } from '@/app/components/base/icons/src/vender/line/arrows'
import type {
BlockEnum,
} from '../types'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
type WorkflowChecklistProps = {
disabled: boolean
}
const WorkflowChecklist = ({
disabled,
}: WorkflowChecklistProps) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const edges = useEdges<CommonEdgeType>()
const nodes = useNodes()
const needWarningNodes = useChecklist(nodes, edges)
const { handleNodeSelect } = useNodesInteractions()
const handleChecklistItemClick = (item: ChecklistItem) => {
if (!item.canNavigate)
return
handleNodeSelect(item.id)
setOpen(false)
}
return (
<PortalToFollowElem
placement='bottom-end'
offset={{
mainAxis: 12,
crossAxis: 4,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger onClick={() => !disabled && setOpen(v => !v)}>
<div
className={cn(
'relative ml-0.5 flex h-7 w-7 items-center justify-center rounded-md',
disabled && 'cursor-not-allowed opacity-50',
)}
>
<div
className={cn('group flex h-full w-full cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
>
<RiListCheck3
className={cn('h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')}
/>
</div>
{
!!needWarningNodes.length && (
<div className='absolute -right-1.5 -top-1.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full border border-gray-100 bg-[#F79009] text-[11px] font-semibold text-white'>
{needWarningNodes.length}
</div>
)
}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[12]'>
<div
className='w-[420px] overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg'
style={{
maxHeight: 'calc(2 / 3 * 100vh)',
}}
>
<div className='text-md sticky top-0 z-[1] flex h-[44px] items-center bg-components-panel-bg pl-4 pr-3 pt-3 font-semibold text-text-primary'>
<div className='grow'>{t('workflow.panel.checklist')}{needWarningNodes.length ? `(${needWarningNodes.length})` : ''}</div>
<div
className='flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center'
onClick={() => setOpen(false)}
>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
</div>
<div className='pb-2'>
{
!!needWarningNodes.length && (
<>
<div className='px-4 pt-1 text-xs text-text-tertiary'>{t('workflow.panel.checklistTip')}</div>
<div className='px-4 py-2'>
{
needWarningNodes.map(node => (
<div
key={node.id}
className={cn(
'group mb-2 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs last-of-type:mb-0',
node.canNavigate ? 'cursor-pointer' : 'cursor-default opacity-80',
)}
onClick={() => handleChecklistItemClick(node)}
>
<div className='flex h-9 items-center p-2 text-xs font-medium text-text-secondary'>
<BlockIcon
type={node.type as BlockEnum}
className='mr-1.5'
toolIcon={node.toolIcon}
/>
<span className='grow truncate'>
{node.title}
</span>
{
node.canNavigate && (
<div className='flex h-4 w-[60px] shrink-0 items-center justify-center gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100'>
<span className='whitespace-nowrap text-xs font-medium leading-4 text-primary-600'>
{t('workflow.panel.goTo')}
</span>
<IconR className='h-3.5 w-3.5 text-primary-600' />
</div>
)
}
</div>
<div
className={cn(
'rounded-b-lg border-t-[0.5px] border-divider-regular',
(node.unConnected || node.errorMessage) && 'bg-gradient-to-r from-components-badge-bg-orange-soft to-transparent',
)}
>
{
node.unConnected && (
<div className='px-3 py-1 first:pt-1.5 last:pb-1.5'>
<div className='flex text-xs leading-4 text-text-tertiary'>
<Warning className='mr-2 mt-[2px] h-3 w-3 text-[#F79009]' />
{t('workflow.common.needConnectTip')}
</div>
</div>
)
}
{
node.errorMessage && (
<div className='px-3 py-1 first:pt-1.5 last:pb-1.5'>
<div className='flex text-xs leading-4 text-text-tertiary'>
<Warning className='mr-2 mt-[2px] h-3 w-3 text-[#F79009]' />
{node.errorMessage}
</div>
</div>
)
}
</div>
</div>
))
}
</div>
</>
)
}
{
!needWarningNodes.length && (
<div className='mx-4 mb-3 rounded-lg bg-components-panel-bg py-4 text-center text-xs text-text-tertiary'>
<ChecklistSquare className='mx-auto mb-[5px] h-8 w-8 text-text-quaternary' />
{t('workflow.panel.checklistResolved')}
</div>
)
}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(WorkflowChecklist)

View File

@@ -0,0 +1,43 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useStore } from '@/app/components/workflow/store'
import useTimestamp from '@/hooks/use-timestamp'
const EditingTitle = () => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const { formatTimeFromNow } = useFormatTimeFromNow()
const draftUpdatedAt = useStore(state => state.draftUpdatedAt)
const publishedAt = useStore(state => state.publishedAt)
const isSyncingWorkflowDraft = useStore(s => s.isSyncingWorkflowDraft)
const maximizeCanvas = useStore(s => s.maximizeCanvas)
return (
<div className={`system-xs-regular flex h-[18px] min-w-[300px] items-center whitespace-nowrap text-text-tertiary ${maximizeCanvas ? 'ml-2' : ''}`}>
{
!!draftUpdatedAt && (
<>
{t('workflow.common.autoSaved')} {formatTime(draftUpdatedAt / 1000, 'HH:mm:ss')}
</>
)
}
<span className='mx-1 flex items-center'>·</span>
{
publishedAt
? `${t('workflow.common.published')} ${formatTimeFromNow(publishedAt)}`
: t('workflow.common.unpublished')
}
{
isSyncingWorkflowDraft && (
<>
<span className='mx-1 flex items-center'>·</span>
{t('workflow.common.syncingData')}
</>
)
}
</div>
)
}
export default memo(EditingTitle)

View File

@@ -0,0 +1,41 @@
import { memo } from 'react'
import Button from '@/app/components/base/button'
import { Env } from '@/app/components/base/icons/src/vender/line/others'
import { useStore } from '@/app/components/workflow/store'
import useTheme from '@/hooks/use-theme'
import cn from '@/utils/classnames'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
const EnvButton = ({ disabled }: { disabled: boolean }) => {
const { theme } = useTheme()
const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel)
const showEnvPanel = useStore(s => s.showEnvPanel)
const setShowEnvPanel = useStore(s => s.setShowEnvPanel)
const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel)
const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel)
const { closeAllInputFieldPanels } = useInputFieldPanel()
const handleClick = () => {
setShowEnvPanel(true)
setShowChatVariablePanel(false)
setShowGlobalVariablePanel(false)
setShowDebugAndPreviewPanel(false)
closeAllInputFieldPanels()
}
return (
<Button
className={cn(
'p-2',
theme === 'dark' && showEnvPanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
)}
variant='ghost'
disabled={disabled}
onClick={handleClick}
>
<Env className='h-4 w-4 text-components-button-secondary-text' />
</Button>
)
}
export default memo(EnvButton)

View File

@@ -0,0 +1,41 @@
import { memo } from 'react'
import Button from '@/app/components/base/button'
import { GlobalVariable } from '@/app/components/base/icons/src/vender/line/others'
import { useStore } from '@/app/components/workflow/store'
import useTheme from '@/hooks/use-theme'
import cn from '@/utils/classnames'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
const GlobalVariableButton = ({ disabled }: { disabled: boolean }) => {
const { theme } = useTheme()
const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel)
const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel)
const setShowEnvPanel = useStore(s => s.setShowEnvPanel)
const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel)
const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel)
const { closeAllInputFieldPanels } = useInputFieldPanel()
const handleClick = () => {
setShowGlobalVariablePanel(true)
setShowEnvPanel(false)
setShowChatVariablePanel(false)
setShowDebugAndPreviewPanel(false)
closeAllInputFieldPanels()
}
return (
<Button
className={cn(
'p-2',
theme === 'dark' && showGlobalVariablePanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
)}
disabled={disabled}
onClick={handleClick}
variant='ghost'
>
<GlobalVariable className='h-4 w-4 text-components-button-secondary-text' />
</Button>
)
}
export default memo(GlobalVariableButton)

View File

@@ -0,0 +1,90 @@
import {
useCallback,
} from 'react'
import { useNodes } from 'reactflow'
import {
useStore,
useWorkflowStore,
} from '../store'
import type { StartNodeType } from '../nodes/start/types'
import {
useNodesInteractions,
useNodesReadOnly,
useWorkflowRun,
} from '../hooks'
import Divider from '../../base/divider'
import type { RunAndHistoryProps } from './run-and-history'
import RunAndHistory from './run-and-history'
import EditingTitle from './editing-title'
import EnvButton from './env-button'
import VersionHistoryButton from './version-history-button'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
import GlobalVariableButton from './global-variable-button'
export type HeaderInNormalProps = {
components?: {
left?: React.ReactNode
middle?: React.ReactNode
chatVariableTrigger?: React.ReactNode
}
runAndHistoryProps?: RunAndHistoryProps
}
const HeaderInNormal = ({
components,
runAndHistoryProps,
}: HeaderInNormalProps) => {
const workflowStore = useWorkflowStore()
const { nodesReadOnly } = useNodesReadOnly()
const { handleNodeSelect } = useNodesInteractions()
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
const setShowEnvPanel = useStore(s => s.setShowEnvPanel)
const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel)
const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel)
const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel)
const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel)
const nodes = useNodes<StartNodeType>()
const selectedNode = nodes.find(node => node.data.selected)
const { handleBackupDraft } = useWorkflowRun()
const { closeAllInputFieldPanels } = useInputFieldPanel()
const onStartRestoring = useCallback(() => {
workflowStore.setState({ isRestoring: true })
handleBackupDraft()
// clear right panel
if (selectedNode)
handleNodeSelect(selectedNode.id, true)
setShowWorkflowVersionHistoryPanel(true)
setShowEnvPanel(false)
setShowDebugAndPreviewPanel(false)
setShowVariableInspectPanel(false)
setShowChatVariablePanel(false)
setShowGlobalVariablePanel(false)
closeAllInputFieldPanels()
}, [workflowStore, handleBackupDraft, selectedNode, handleNodeSelect, setShowWorkflowVersionHistoryPanel, setShowEnvPanel, setShowDebugAndPreviewPanel, setShowVariableInspectPanel, setShowChatVariablePanel, setShowGlobalVariablePanel])
return (
<div className='flex w-full items-center justify-between'>
<div>
<EditingTitle />
</div>
<div>
<ScrollToSelectedNodeButton />
</div>
<div className='flex items-center gap-2'>
{components?.left}
<Divider type='vertical' className='mx-auto h-3.5' />
<RunAndHistory {...runAndHistoryProps} />
<div className='shrink-0 cursor-pointer rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs backdrop-blur-[10px]'>
{components?.chatVariableTrigger}
<EnvButton disabled={nodesReadOnly} />
<GlobalVariableButton disabled={nodesReadOnly} />
</div>
{components?.middle}
<VersionHistoryButton onClick={onStartRestoring} />
</div>
</div>
)
}
export default HeaderInNormal

View File

@@ -0,0 +1,111 @@
import {
useCallback,
} from 'react'
import { RiHistoryLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import {
useStore,
useWorkflowStore,
} from '../store'
import {
WorkflowVersion,
} from '../types'
import {
useNodesSyncDraft,
useWorkflowRun,
} from '../hooks'
import Toast from '../../base/toast'
import RestoringTitle from './restoring-title'
import Button from '@/app/components/base/button'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import { useHooksStore } from '../hooks-store'
import useTheme from '@/hooks/use-theme'
import cn from '@/utils/classnames'
export type HeaderInRestoringProps = {
onRestoreSettled?: () => void
}
const HeaderInRestoring = ({
onRestoreSettled,
}: HeaderInRestoringProps) => {
const { t } = useTranslation()
const { theme } = useTheme()
const workflowStore = useWorkflowStore()
const configsMap = useHooksStore(s => s.configsMap)
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
const {
deleteAllInspectVars,
} = workflowStore.getState()
const currentVersion = useStore(s => s.currentVersion)
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
const {
handleLoadBackupDraft,
} = useWorkflowRun()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
const handleCancelRestore = useCallback(() => {
handleLoadBackupDraft()
workflowStore.setState({ isRestoring: false })
setShowWorkflowVersionHistoryPanel(false)
}, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel])
const handleRestore = useCallback(() => {
setShowWorkflowVersionHistoryPanel(false)
workflowStore.setState({ isRestoring: false })
workflowStore.setState({ backupDraft: undefined })
handleSyncWorkflowDraft(true, false, {
onSuccess: () => {
Toast.notify({
type: 'success',
message: t('workflow.versionHistory.action.restoreSuccess'),
})
},
onError: () => {
Toast.notify({
type: 'error',
message: t('workflow.versionHistory.action.restoreFailure'),
})
},
onSettled: () => {
onRestoreSettled?.()
},
})
deleteAllInspectVars()
invalidAllLastRun()
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
return (
<>
<div>
<RestoringTitle />
</div>
<div className=' flex items-center justify-end gap-x-2'>
<Button
onClick={handleRestore}
disabled={!currentVersion || currentVersion.version === WorkflowVersion.Draft}
variant='primary'
className={cn(
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
)}
>
{t('workflow.common.restore')}
</Button>
<Button
onClick={handleCancelRestore}
className={cn(
'text-components-button-secondary-accent-text',
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
)}
>
<div className='flex items-center gap-x-0.5'>
<RiHistoryLine className='h-4 w-4' />
<span className='px-0.5'>{t('workflow.common.exitVersions')}</span>
</div>
</Button>
</div>
</>
)
}
export default HeaderInRestoring

View File

@@ -0,0 +1,56 @@
import {
useCallback,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
useWorkflowStore,
} from '../store'
import {
useWorkflowRun,
} from '../hooks'
import Divider from '../../base/divider'
import RunningTitle from './running-title'
import type { ViewHistoryProps } from './view-history'
import ViewHistory from './view-history'
import Button from '@/app/components/base/button'
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
export type HeaderInHistoryProps = {
viewHistoryProps?: ViewHistoryProps
}
const HeaderInHistory = ({
viewHistoryProps,
}: HeaderInHistoryProps) => {
const { t } = useTranslation()
const workflowStore = useWorkflowStore()
const {
handleLoadBackupDraft,
} = useWorkflowRun()
const handleGoBackToEdit = useCallback(() => {
handleLoadBackupDraft()
workflowStore.setState({ historyWorkflowData: undefined })
}, [workflowStore, handleLoadBackupDraft])
return (
<>
<div>
<RunningTitle />
</div>
<div className='flex items-center space-x-2'>
<ViewHistory {...viewHistoryProps} withText />
<Divider type='vertical' className='mx-auto h-3.5' />
<Button
variant='primary'
onClick={handleGoBackToEdit}
>
<ArrowNarrowLeft className='mr-1 h-4 w-4' />
{t('workflow.common.goBackToEdit')}
</Button>
</div>
</>
)
}
export default HeaderInHistory

View File

@@ -0,0 +1,69 @@
import { usePathname } from 'next/navigation'
import {
useWorkflowMode,
} from '../hooks'
import type { HeaderInNormalProps } from './header-in-normal'
import HeaderInNormal from './header-in-normal'
import type { HeaderInHistoryProps } from './header-in-view-history'
import type { HeaderInRestoringProps } from './header-in-restoring'
import { useStore } from '../store'
import dynamic from 'next/dynamic'
const HeaderInHistory = dynamic(() => import('./header-in-view-history'), {
ssr: false,
})
const HeaderInRestoring = dynamic(() => import('./header-in-restoring'), {
ssr: false,
})
export type HeaderProps = {
normal?: HeaderInNormalProps
viewHistory?: HeaderInHistoryProps
restoring?: HeaderInRestoringProps
}
const Header = ({
normal: normalProps,
viewHistory: viewHistoryProps,
restoring: restoringProps,
}: HeaderProps) => {
const pathname = usePathname()
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const {
normal,
restoring,
viewHistory,
} = useWorkflowMode()
const maximizeCanvas = useStore(s => s.maximizeCanvas)
return (
<div
className='absolute left-0 top-7 z-10 flex h-0 w-full items-center justify-between bg-mask-top2bottom-gray-50-to-transparent px-3'
>
{(inWorkflowCanvas || isPipelineCanvas) && maximizeCanvas && <div className='h-14 w-[52px]' />}
{
normal && (
<HeaderInNormal
{...normalProps}
/>
)
}
{
viewHistory && (
<HeaderInHistory
{...viewHistoryProps}
/>
)
}
{
restoring && (
<HeaderInRestoring
{...restoringProps}
/>
)
}
</div>
)
}
export default Header

View File

@@ -0,0 +1,49 @@
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { useStore } from '../store'
import { WorkflowVersion } from '../types'
import useTimestamp from '@/hooks/use-timestamp'
const RestoringTitle = () => {
const { t } = useTranslation()
const { formatTimeFromNow } = useFormatTimeFromNow()
const { formatTime } = useTimestamp()
const currentVersion = useStore(state => state.currentVersion)
const isDraft = currentVersion?.version === WorkflowVersion.Draft
const publishStatus = isDraft ? t('workflow.common.unpublished') : t('workflow.common.published')
const versionName = useMemo(() => {
if (isDraft)
return t('workflow.versionHistory.currentDraft')
return currentVersion?.marked_name || t('workflow.versionHistory.defaultName')
}, [currentVersion, t, isDraft])
return (
<div className='flex flex-col gap-y-0.5'>
<div className='flex items-center gap-x-1'>
<span className='system-sm-semibold text-text-primary'>
{versionName}
</span>
<span className='system-2xs-medium-uppercase rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-1 py-0.5 text-text-accent-secondary'>
{t('workflow.common.viewOnly')}
</span>
</div>
<div className='system-xs-regular flex h-4 items-center gap-x-1 text-text-tertiary'>
{
currentVersion && (
<>
<span>{publishStatus}</span>
<span>·</span>
<span>{`${formatTimeFromNow((isDraft ? currentVersion.updated_at : currentVersion.created_at) * 1000)} ${formatTime(currentVersion.created_at, 'HH:mm:ss')}`}</span>
<span>·</span>
<span>{currentVersion?.created_by?.name || ''}</span>
</>
)
}
</div>
</div>
)
}
export default memo(RestoringTitle)

View File

@@ -0,0 +1,75 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiPlayLargeLine,
} from '@remixicon/react'
import {
useNodesReadOnly,
useWorkflowStartRun,
} from '../hooks'
import type { ViewHistoryProps } from './view-history'
import ViewHistory from './view-history'
import Checklist from './checklist'
import cn from '@/utils/classnames'
import RunMode from './run-mode'
const PreviewMode = memo(() => {
const { t } = useTranslation()
const { handleWorkflowStartRunInChatflow } = useWorkflowStartRun()
return (
<div
className={cn(
'flex h-7 items-center rounded-md px-2.5 text-[13px] font-medium text-components-button-secondary-accent-text',
'cursor-pointer hover:bg-state-accent-hover',
)}
onClick={() => handleWorkflowStartRunInChatflow()}
>
<RiPlayLargeLine className='mr-1 h-4 w-4' />
{t('workflow.common.debugAndPreview')}
</div>
)
})
export type RunAndHistoryProps = {
showRunButton?: boolean
runButtonText?: string
isRunning?: boolean
showPreviewButton?: boolean
viewHistoryProps?: ViewHistoryProps
components?: {
RunMode?: React.ComponentType<
{
text?: string
}
>
}
}
const RunAndHistory = ({
showRunButton,
runButtonText,
showPreviewButton,
viewHistoryProps,
components,
}: RunAndHistoryProps) => {
const { nodesReadOnly } = useNodesReadOnly()
const { RunMode: CustomRunMode } = components || {}
return (
<div className='flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-0.5 shadow-xs'>
{
showRunButton && (
CustomRunMode ? <CustomRunMode text={runButtonText} /> : <RunMode text={runButtonText} />
)
}
{
showPreviewButton && <PreviewMode />
}
<div className='mx-0.5 h-3.5 w-[1px] bg-divider-regular'></div>
<ViewHistory {...viewHistoryProps} />
<Checklist disabled={nodesReadOnly} />
</div>
)
}
export default memo(RunAndHistory)

View File

@@ -0,0 +1,167 @@
import React, { useCallback, useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useWorkflowRun, useWorkflowRunValidation, useWorkflowStartRun } from '@/app/components/workflow/hooks'
import { useStore } from '@/app/components/workflow/store'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
import cn from '@/utils/classnames'
import { RiLoader2Line, RiPlayLargeLine } from '@remixicon/react'
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options'
import TestRunMenu, { type TestRunMenuRef, type TriggerOption, TriggerType } from './test-run-menu'
import { useToastContext } from '@/app/components/base/toast'
type RunModeProps = {
text?: string
}
const RunMode = ({
text,
}: RunModeProps) => {
const { t } = useTranslation()
const {
handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow,
} = useWorkflowStartRun()
const { handleStopRun } = useWorkflowRun()
const { validateBeforeRun, warningNodes } = useWorkflowRunValidation()
const workflowRunningData = useStore(s => s.workflowRunningData)
const isListening = useStore(s => s.isListening)
const status = workflowRunningData?.result.status
const isRunning = status === WorkflowRunningStatus.Running || isListening
const dynamicOptions = useDynamicTestRunOptions()
const testRunMenuRef = useRef<TestRunMenuRef>(null)
const { notify } = useToastContext()
useEffect(() => {
// @ts-expect-error - Dynamic property for backward compatibility with keyboard shortcuts
window._toggleTestRunDropdown = () => {
testRunMenuRef.current?.toggle()
}
return () => {
// @ts-expect-error - Dynamic property cleanup
delete window._toggleTestRunDropdown
}
}, [])
const handleStop = useCallback(() => {
handleStopRun(workflowRunningData?.task_id || '')
}, [handleStopRun, workflowRunningData?.task_id])
const handleTriggerSelect = useCallback((option: TriggerOption) => {
// Validate checklist before running any workflow
let isValid: boolean = true
warningNodes.forEach((node) => {
if (node.id === option.nodeId)
isValid = false
})
if (!isValid) {
notify({ type: 'error', message: t('workflow.panel.checklistTip') })
return
}
if (option.type === TriggerType.UserInput) {
handleWorkflowStartRunInWorkflow()
}
else if (option.type === TriggerType.Schedule) {
handleWorkflowTriggerScheduleRunInWorkflow(option.nodeId)
}
else if (option.type === TriggerType.Webhook) {
if (option.nodeId)
handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: option.nodeId })
}
else if (option.type === TriggerType.Plugin) {
if (option.nodeId)
handleWorkflowTriggerPluginRunInWorkflow(option.nodeId)
}
else if (option.type === TriggerType.All) {
const targetNodeIds = option.relatedNodeIds?.filter(Boolean)
if (targetNodeIds && targetNodeIds.length > 0)
handleWorkflowRunAllTriggersInWorkflow(targetNodeIds)
}
else {
// Placeholder for trigger-specific execution logic for schedule, webhook, plugin types
console.log('TODO: Handle trigger execution for type:', option.type, 'nodeId:', option.nodeId)
}
}, [
validateBeforeRun,
handleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow,
])
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v.type === EVENT_WORKFLOW_STOP)
handleStop()
})
return (
<div className='flex items-center gap-x-px'>
{
isRunning
? (
<button
type='button'
className={cn(
'system-xs-medium flex h-7 cursor-not-allowed items-center gap-x-1 rounded-l-md bg-state-accent-hover px-1.5 text-text-accent',
)}
disabled={true}
>
<RiLoader2Line className='mr-1 size-4 animate-spin' />
{isListening ? t('workflow.common.listening') : t('workflow.common.running')}
</button>
)
: (
<TestRunMenu
ref={testRunMenuRef}
options={dynamicOptions}
onSelect={handleTriggerSelect}
>
<div
className={cn(
'system-xs-medium flex h-7 cursor-pointer items-center gap-x-1 rounded-md px-1.5 text-text-accent hover:bg-state-accent-hover',
)}
style={{ userSelect: 'none' }}
>
<RiPlayLargeLine className='mr-1 size-4' />
{text ?? t('workflow.common.run')}
<div className='system-kbd flex items-center gap-x-0.5 text-text-tertiary'>
<div className='flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray'>
{getKeyboardKeyNameBySystem('alt')}
</div>
<div className='flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray'>
R
</div>
</div>
</div>
</TestRunMenu>
)
}
{
isRunning && (
<button
type='button'
className={cn(
'flex size-7 items-center justify-center rounded-r-md bg-state-accent-active',
)}
onClick={handleStop}
>
<StopCircle className='size-4 text-text-accent' />
</button>
)
}
</div>
)
}
export default React.memo(RunMode)

View File

@@ -0,0 +1,25 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useIsChatMode } from '../hooks'
import { useStore } from '../store'
import { formatWorkflowRunIdentifier } from '../utils'
import { ClockPlay } from '@/app/components/base/icons/src/vender/line/time'
const RunningTitle = () => {
const { t } = useTranslation()
const isChatMode = useIsChatMode()
const historyWorkflowData = useStore(s => s.historyWorkflowData)
return (
<div className='flex h-[18px] items-center text-xs text-gray-500'>
<ClockPlay className='mr-1 h-3 w-3 text-gray-500' />
<span>{isChatMode ? `Test Chat${formatWorkflowRunIdentifier(historyWorkflowData?.finished_at)}` : `Test Run${formatWorkflowRunIdentifier(historyWorkflowData?.finished_at)}`}</span>
<span className='mx-1'>·</span>
<span className='ml-1 flex h-[18px] items-center rounded-[5px] border border-indigo-300 bg-white/[0.48] px-1 text-[10px] font-semibold uppercase text-indigo-600'>
{t('workflow.common.viewOnly')}
</span>
</div>
)
}
export default memo(RunningTitle)

View File

@@ -0,0 +1,34 @@
import type { FC } from 'react'
import { useCallback } from 'react'
import { useNodes } from 'reactflow'
import { useTranslation } from 'react-i18next'
import type { CommonNodeType } from '../types'
import { scrollToWorkflowNode } from '../utils/node-navigation'
import cn from '@/utils/classnames'
const ScrollToSelectedNodeButton: FC = () => {
const { t } = useTranslation()
const nodes = useNodes<CommonNodeType>()
const selectedNode = nodes.find(node => node.data.selected)
const handleScrollToSelectedNode = useCallback(() => {
if (!selectedNode) return
scrollToWorkflowNode(selectedNode.id)
}, [selectedNode])
if (!selectedNode)
return null
return (
<div
className={cn(
'system-xs-medium flex h-6 cursor-pointer items-center justify-center whitespace-nowrap rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-3 text-text-tertiary shadow-lg backdrop-blur-sm transition-colors duration-200 hover:text-text-accent',
)}
onClick={handleScrollToSelectedNode}
>
{t('workflow.panel.scrollToSelectedNode')}
</div>
)
}
export default ScrollToSelectedNodeButton

View File

@@ -0,0 +1,251 @@
import {
type MouseEvent,
type MouseEventHandler,
type ReactElement,
cloneElement,
forwardRef,
isValidElement,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import ShortcutsName from '../shortcuts-name'
export enum TriggerType {
UserInput = 'user_input',
Schedule = 'schedule',
Webhook = 'webhook',
Plugin = 'plugin',
All = 'all',
}
export type TriggerOption = {
id: string
type: TriggerType
name: string
icon: React.ReactNode
nodeId?: string
relatedNodeIds?: string[]
enabled: boolean
}
export type TestRunOptions = {
userInput?: TriggerOption
triggers: TriggerOption[]
runAll?: TriggerOption
}
type TestRunMenuProps = {
options: TestRunOptions
onSelect: (option: TriggerOption) => void
children: React.ReactNode
}
export type TestRunMenuRef = {
toggle: () => void
}
type ShortcutMapping = {
option: TriggerOption
shortcutKey: string
}
const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => {
const mappings: ShortcutMapping[] = []
if (options.userInput && options.userInput.enabled !== false)
mappings.push({ option: options.userInput, shortcutKey: '~' })
let numericShortcut = 0
if (options.runAll && options.runAll.enabled !== false)
mappings.push({ option: options.runAll, shortcutKey: String(numericShortcut++) })
options.triggers.forEach((trigger) => {
if (trigger.enabled !== false)
mappings.push({ option: trigger, shortcutKey: String(numericShortcut++) })
})
return mappings
}
const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
options,
onSelect,
children,
}, ref) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const shortcutMappings = useMemo(() => buildShortcutMappings(options), [options])
const shortcutKeyById = useMemo(() => {
const map = new Map<string, string>()
shortcutMappings.forEach(({ option, shortcutKey }) => {
map.set(option.id, shortcutKey)
})
return map
}, [shortcutMappings])
const handleSelect = useCallback((option: TriggerOption) => {
onSelect(option)
setOpen(false)
}, [onSelect])
const enabledOptions = useMemo(() => {
const flattened: TriggerOption[] = []
if (options.userInput)
flattened.push(options.userInput)
if (options.runAll)
flattened.push(options.runAll)
flattened.push(...options.triggers)
return flattened.filter(option => option.enabled !== false)
}, [options])
const hasSingleEnabledOption = enabledOptions.length === 1
const soleEnabledOption = hasSingleEnabledOption ? enabledOptions[0] : undefined
const runSoleOption = useCallback(() => {
if (soleEnabledOption)
handleSelect(soleEnabledOption)
}, [handleSelect, soleEnabledOption])
useImperativeHandle(ref, () => ({
toggle: () => {
if (hasSingleEnabledOption) {
runSoleOption()
return
}
setOpen(prev => !prev)
},
}), [hasSingleEnabledOption, runSoleOption])
useEffect(() => {
if (!open)
return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey)
return
const normalizedKey = event.key === '`' ? '~' : event.key
const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey)
if (mapping) {
event.preventDefault()
handleSelect(mapping.option)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('keydown', handleKeyDown)
}
}, [handleSelect, open, shortcutMappings])
const renderOption = (option: TriggerOption) => {
const shortcutKey = shortcutKeyById.get(option.id)
return (
<div
key={option.id}
className='system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'
onClick={() => handleSelect(option)}
>
<div className='flex min-w-0 flex-1 items-center'>
<div className='flex h-6 w-6 shrink-0 items-center justify-center'>
{option.icon}
</div>
<span className='ml-2 truncate'>{option.name}</span>
</div>
{shortcutKey && (
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
)}
</div>
)
}
const hasUserInput = !!options.userInput && options.userInput.enabled !== false
const hasTriggers = options.triggers.some(trigger => trigger.enabled !== false)
const hasRunAll = !!options.runAll && options.runAll.enabled !== false
if (hasSingleEnabledOption && soleEnabledOption) {
const handleRunClick = (event?: MouseEvent<HTMLElement>) => {
if (event?.defaultPrevented)
return
runSoleOption()
}
if (isValidElement(children)) {
const childElement = children as ReactElement<{ onClick?: MouseEventHandler<HTMLElement> }>
const originalOnClick = childElement.props?.onClick
return cloneElement(childElement, {
onClick: (event: MouseEvent<HTMLElement>) => {
if (typeof originalOnClick === 'function')
originalOnClick(event)
if (event?.defaultPrevented)
return
runSoleOption()
},
})
}
return (
<span onClick={handleRunClick}>
{children}
</span>
)
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{ mainAxis: 8, crossAxis: -4 }}
>
<PortalToFollowElemTrigger asChild onClick={() => setOpen(!open)}>
<div style={{ userSelect: 'none' }}>
{children}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[12]'>
<div className='w-[284px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg'>
<div className='mb-2 px-3 pt-2 text-sm font-medium text-text-primary'>
{t('workflow.common.chooseStartNodeToRun')}
</div>
<div>
{hasUserInput && renderOption(options.userInput!)}
{(hasTriggers || hasRunAll) && hasUserInput && (
<div className='mx-3 my-1 h-px bg-divider-subtle' />
)}
{hasRunAll && renderOption(options.runAll!)}
{hasTriggers && options.triggers
.filter(trigger => trigger.enabled !== false)
.map(trigger => renderOption(trigger))}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
})
TestRunMenu.displayName = 'TestRunMenu'
export default TestRunMenu

View File

@@ -0,0 +1,66 @@
import type { FC } from 'react'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiArrowGoBackLine,
RiArrowGoForwardFill,
} from '@remixicon/react'
import TipPopup from '../operator/tip-popup'
import { useWorkflowHistoryStore } from '../workflow-history-store'
import Divider from '../../base/divider'
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
import classNames from '@/utils/classnames'
export type UndoRedoProps = { handleUndo: () => void; handleRedo: () => void }
const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
const { t } = useTranslation()
const { store } = useWorkflowHistoryStore()
const [buttonsDisabled, setButtonsDisabled] = useState({ undo: true, redo: true })
useEffect(() => {
const unsubscribe = store.temporal.subscribe((state) => {
setButtonsDisabled({
undo: state.pastStates.length === 0,
redo: state.futureStates.length === 0,
})
})
return () => unsubscribe()
}, [store])
const { nodesReadOnly } = useNodesReadOnly()
return (
<div className='flex items-center space-x-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-[5px]'>
<TipPopup title={t('workflow.common.undo')!} shortcuts={['ctrl', 'z']}>
<div
data-tooltip-id='workflow.undo'
className={
classNames('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
(nodesReadOnly || buttonsDisabled.undo)
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')}
onClick={() => !nodesReadOnly && !buttonsDisabled.undo && handleUndo()}
>
<RiArrowGoBackLine className='h-4 w-4' />
</div>
</TipPopup >
<TipPopup title={t('workflow.common.redo')!} shortcuts={['ctrl', 'y']}>
<div
data-tooltip-id='workflow.redo'
className={
classNames('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
(nodesReadOnly || buttonsDisabled.redo)
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
)}
onClick={() => !nodesReadOnly && !buttonsDisabled.redo && handleRedo()}
>
<RiArrowGoForwardFill className='h-4 w-4' />
</div>
</TipPopup>
<Divider type='vertical' className="mx-0.5 h-3.5" />
<ViewWorkflowHistory />
</div >
)
}
export default memo(UndoRedo)

View File

@@ -0,0 +1,71 @@
import React, { type FC, useCallback } from 'react'
import { RiHistoryLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useKeyPress } from 'ahooks'
import Button from '../../base/button'
import Tooltip from '../../base/tooltip'
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../utils'
import useTheme from '@/hooks/use-theme'
import cn from '@/utils/classnames'
type VersionHistoryButtonProps = {
onClick: () => Promise<unknown> | unknown
}
const VERSION_HISTORY_SHORTCUT = ['ctrl', '⇧', 'H']
const PopupContent = React.memo(() => {
const { t } = useTranslation()
return (
<div className='flex items-center gap-x-1'>
<div className='system-xs-medium px-0.5 text-text-secondary'>
{t('workflow.common.versionHistory')}
</div>
<div className='flex items-center gap-x-0.5'>
{VERSION_HISTORY_SHORTCUT.map(key => (
<span
key={key}
className='system-kbd rounded-[4px] bg-components-kbd-bg-white px-[1px] text-text-tertiary'
>
{getKeyboardKeyNameBySystem(key)}
</span>
))}
</div>
</div>
)
})
PopupContent.displayName = 'PopupContent'
const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({
onClick,
}) => {
const { theme } = useTheme()
const handleViewVersionHistory = useCallback(async () => {
await onClick?.()
}, [onClick])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.h`, (e) => {
e.preventDefault()
handleViewVersionHistory()
}, { exactMatch: true, useCapture: true })
return <Tooltip
popupContent={<PopupContent />}
noDecoration
popupClassName='rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg
shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px] p-1.5'
>
<Button
className={cn(
'p-2',
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
)}
onClick={handleViewVersionHistory}
>
<RiHistoryLine className='h-4 w-4 text-components-button-secondary-text' />
</Button>
</Tooltip>
}
export default VersionHistoryButton

View File

@@ -0,0 +1,222 @@
import {
memo,
useState,
} from 'react'
import type { Fetcher } from 'swr'
import useSWR from 'swr'
import { useTranslation } from 'react-i18next'
import { noop } from 'lodash-es'
import {
RiCheckboxCircleLine,
RiCloseLine,
RiErrorWarningLine,
} from '@remixicon/react'
import {
useIsChatMode,
useNodesInteractions,
useWorkflowInteractions,
useWorkflowRun,
} from '../hooks'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
import { ControlMode, WorkflowRunningStatus } from '../types'
import { formatWorkflowRunIdentifier } from '../utils'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Tooltip from '@/app/components/base/tooltip'
import {
ClockPlay,
ClockPlaySlim,
} from '@/app/components/base/icons/src/vender/line/time'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import Loading from '@/app/components/base/loading'
import {
useStore,
useWorkflowStore,
} from '@/app/components/workflow/store'
import type { WorkflowRunHistoryResponse } from '@/types/workflow'
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
export type ViewHistoryProps = {
withText?: boolean
onClearLogAndMessageModal?: () => void
historyUrl?: string
historyFetcher?: Fetcher<WorkflowRunHistoryResponse, string>
}
const ViewHistory = ({
withText,
onClearLogAndMessageModal,
historyUrl,
historyFetcher,
}: ViewHistoryProps) => {
const { t } = useTranslation()
const isChatMode = useIsChatMode()
const [open, setOpen] = useState(false)
const { formatTimeFromNow } = useFormatTimeFromNow()
const {
handleNodesCancelSelected,
} = useNodesInteractions()
const {
handleCancelDebugAndPreviewPanel,
} = useWorkflowInteractions()
const workflowStore = useWorkflowStore()
const setControlMode = useStore(s => s.setControlMode)
const historyWorkflowData = useStore(s => s.historyWorkflowData)
const { handleBackupDraft } = useWorkflowRun()
const { closeAllInputFieldPanels } = useInputFieldPanel()
const fetcher = historyFetcher ?? (noop as Fetcher<WorkflowRunHistoryResponse, string>)
const {
data,
isLoading,
} = useSWR((open && historyUrl && historyFetcher) ? historyUrl : null, fetcher)
return (
(
<PortalToFollowElem
placement={withText ? 'bottom-start' : 'bottom-end'}
offset={{
mainAxis: 4,
crossAxis: withText ? -8 : 10,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
{
withText && (
<div className={cn(
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
open && 'bg-components-button-secondary-bg-hover',
)}>
<ClockPlay
className={'mr-1 h-4 w-4'}
/>
{t('workflow.common.showRunHistory')}
</div>
)
}
{
!withText && (
<Tooltip
popupContent={t('workflow.common.viewRunHistory')}
>
<div
className={cn('group flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
onClick={() => {
onClearLogAndMessageModal?.()
}}
>
<ClockPlay className={cn('h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
</div>
</Tooltip>
)
}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[12]'>
<div
className='ml-2 flex w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'
style={{
maxHeight: 'calc(2 / 3 * 100vh)',
}}
>
<div className='sticky top-0 flex items-center justify-between bg-components-panel-bg px-4 pt-3 text-base font-semibold text-text-primary'>
<div className='grow'>{t('workflow.common.runHistory')}</div>
<div
className='flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center'
onClick={() => {
onClearLogAndMessageModal?.()
setOpen(false)
}}
>
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
</div>
</div>
{
isLoading && (
<div className='flex h-10 items-center justify-center'>
<Loading />
</div>
)
}
{
!isLoading && (
<div className='p-2'>
{
!data?.data.length && (
<div className='py-12'>
<ClockPlaySlim className='mx-auto mb-2 h-8 w-8 text-text-quaternary' />
<div className='text-center text-[13px] text-text-quaternary'>
{t('workflow.common.notRunning')}
</div>
</div>
)
}
{
data?.data.map(item => (
<div
key={item.id}
className={cn(
'mb-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] hover:bg-state-base-hover',
item.id === historyWorkflowData?.id && 'bg-state-accent-hover hover:bg-state-accent-hover',
)}
onClick={() => {
workflowStore.setState({
historyWorkflowData: item,
showInputsPanel: false,
showEnvPanel: false,
})
closeAllInputFieldPanels()
handleBackupDraft()
setOpen(false)
handleNodesCancelSelected()
handleCancelDebugAndPreviewPanel()
setControlMode(ControlMode.Hand)
}}
>
{
!isChatMode && item.status === WorkflowRunningStatus.Stopped && (
<AlertTriangle className='mr-1.5 mt-0.5 h-3.5 w-3.5 text-[#F79009]' />
)
}
{
!isChatMode && item.status === WorkflowRunningStatus.Failed && (
<RiErrorWarningLine className='mr-1.5 mt-0.5 h-3.5 w-3.5 text-[#F04438]' />
)
}
{
!isChatMode && item.status === WorkflowRunningStatus.Succeeded && (
<RiCheckboxCircleLine className='mr-1.5 mt-0.5 h-3.5 w-3.5 text-[#12B76A]' />
)
}
<div>
<div
className={cn(
'flex items-center text-[13px] font-medium leading-[18px] text-text-primary',
item.id === historyWorkflowData?.id && 'text-text-accent',
)}
>
{`Test ${isChatMode ? 'Chat' : 'Run'}${formatWorkflowRunIdentifier(item.finished_at)}`}
</div>
<div className='flex items-center text-xs leading-[18px] text-text-tertiary'>
{item.created_by_account?.name} · {formatTimeFromNow((item.finished_at || item.created_at) * 1000)}
</div>
</div>
</div>
))
}
</div>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
)
}
export default memo(ViewHistory)

View File

@@ -0,0 +1,294 @@
import {
memo,
useCallback,
useMemo,
useState,
} from 'react'
import {
RiCloseLine,
RiHistoryLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useStoreApi } from 'reactflow'
import {
useNodesReadOnly,
useWorkflowHistory,
} from '../hooks'
import TipPopup from '../operator/tip-popup'
import type { WorkflowHistoryState } from '../workflow-history-store'
import Divider from '../../base/divider'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { useStore as useAppStore } from '@/app/components/app/store'
import classNames from '@/utils/classnames'
type ChangeHistoryEntry = {
label: string
index: number
state: Partial<WorkflowHistoryState>
}
type ChangeHistoryList = {
pastStates: ChangeHistoryEntry[]
futureStates: ChangeHistoryEntry[]
statesCount: number
}
const ViewWorkflowHistory = () => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const { nodesReadOnly } = useNodesReadOnly()
const { setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({
appDetail: state.appDetail,
setCurrentLogItem: state.setCurrentLogItem,
setShowMessageLogModal: state.setShowMessageLogModal,
})))
const reactFlowStore = useStoreApi()
const { store, getHistoryLabel } = useWorkflowHistory()
const { pastStates, futureStates, undo, redo, clear } = store.temporal.getState()
const [currentHistoryStateIndex, setCurrentHistoryStateIndex] = useState<number>(0)
const handleClearHistory = useCallback(() => {
clear()
setCurrentHistoryStateIndex(0)
}, [clear])
const handleSetState = useCallback(({ index }: ChangeHistoryEntry) => {
const { setEdges, setNodes } = reactFlowStore.getState()
const diff = currentHistoryStateIndex + index
if (diff === 0)
return
if (diff < 0)
undo(diff * -1)
else
redo(diff)
const { edges, nodes } = store.getState()
if (edges.length === 0 && nodes.length === 0)
return
setEdges(edges)
setNodes(nodes)
}, [currentHistoryStateIndex, reactFlowStore, redo, store, undo])
const calculateStepLabel = useCallback((index: number) => {
if (!index)
return
const count = index < 0 ? index * -1 : index
return `${index > 0 ? t('workflow.changeHistory.stepForward', { count }) : t('workflow.changeHistory.stepBackward', { count })}`
}, [t])
const calculateChangeList: ChangeHistoryList = useMemo(() => {
const filterList = (list: any, startIndex = 0, reverse = false) => list.map((state: Partial<WorkflowHistoryState>, index: number) => {
const nodes = (state.nodes || store.getState().nodes) || []
const nodeId = state?.workflowHistoryEventMeta?.nodeId
const targetTitle = nodes.find(n => n.id === nodeId)?.data?.title ?? ''
return {
label: state.workflowHistoryEvent && getHistoryLabel(state.workflowHistoryEvent),
index: reverse ? list.length - 1 - index - startIndex : index - startIndex,
state: {
...state,
workflowHistoryEventMeta: state.workflowHistoryEventMeta ? {
...state.workflowHistoryEventMeta,
nodeTitle: state.workflowHistoryEventMeta.nodeTitle || targetTitle,
} : undefined,
},
}
}).filter(Boolean)
const historyData = {
pastStates: filterList(pastStates, pastStates.length).reverse(),
futureStates: filterList([...futureStates, (!pastStates.length && !futureStates.length) ? undefined : store.getState()].filter(Boolean), 0, true),
statesCount: 0,
}
historyData.statesCount = pastStates.length + futureStates.length
return {
...historyData,
statesCount: pastStates.length + futureStates.length,
}
}, [futureStates, getHistoryLabel, pastStates, store])
const composeHistoryItemLabel = useCallback((nodeTitle: string | undefined, baseLabel: string) => {
if (!nodeTitle)
return baseLabel
return `${nodeTitle} ${baseLabel}`
}, [])
return (
(
<PortalToFollowElem
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: 131,
}}
open={open}
onOpenChange={setOpen}
>
<PortalToFollowElemTrigger onClick={() => !nodesReadOnly && setOpen(v => !v)}>
<TipPopup
title={t('workflow.changeHistory.title')}
>
<div
className={
classNames('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
open && 'bg-state-accent-active text-text-accent',
nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
)}
onClick={() => {
if (nodesReadOnly)
return
setCurrentLogItem()
setShowMessageLogModal(false)
}}
>
<RiHistoryLine className='h-4 w-4' />
</div>
</TipPopup>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[12]'>
<div
className='ml-2 flex min-w-[240px] max-w-[360px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]'
>
<div className='sticky top-0 flex items-center justify-between px-4 pt-3'>
<div className='system-mg-regular grow text-text-secondary'>{t('workflow.changeHistory.title')}</div>
<div
className='flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center'
onClick={() => {
setCurrentLogItem()
setShowMessageLogModal(false)
setOpen(false)
}}
>
<RiCloseLine className='h-4 w-4 text-text-secondary' />
</div>
</div>
{
(
<div
className='overflow-y-auto p-2'
style={{
maxHeight: 'calc(1 / 2 * 100vh)',
}}
>
{
!calculateChangeList.statesCount && (
<div className='py-12'>
<RiHistoryLine className='mx-auto mb-2 h-8 w-8 text-text-tertiary' />
<div className='text-center text-[13px] text-text-tertiary'>
{t('workflow.changeHistory.placeholder')}
</div>
</div>
)
}
<div className='flex flex-col'>
{
calculateChangeList.futureStates.map((item: ChangeHistoryEntry) => (
<div
key={item?.index}
className={cn(
'mb-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] text-text-secondary hover:bg-state-base-hover',
item?.index === currentHistoryStateIndex && 'bg-state-base-hover',
)}
onClick={() => {
handleSetState(item)
setOpen(false)
}}
>
<div>
<div
className={cn(
'flex items-center text-[13px] font-medium leading-[18px] text-text-secondary',
)}
>
{composeHistoryItemLabel(
item?.state?.workflowHistoryEventMeta?.nodeTitle,
item?.label || t('workflow.changeHistory.sessionStart'),
)} ({calculateStepLabel(item?.index)}{item?.index === currentHistoryStateIndex && t('workflow.changeHistory.currentState')})
</div>
</div>
</div>
))
}
{
calculateChangeList.pastStates.map((item: ChangeHistoryEntry) => (
<div
key={item?.index}
className={cn(
'mb-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] hover:bg-state-base-hover',
item?.index === calculateChangeList.statesCount - 1 && 'bg-state-base-hover',
)}
onClick={() => {
handleSetState(item)
setOpen(false)
}}
>
<div>
<div
className={cn(
'flex items-center text-[13px] font-medium leading-[18px] text-text-secondary',
)}
>
{composeHistoryItemLabel(
item?.state?.workflowHistoryEventMeta?.nodeTitle,
item?.label || t('workflow.changeHistory.sessionStart'),
)} ({calculateStepLabel(item?.index)})
</div>
</div>
</div>
))
}
</div>
</div>
)
}
{
!!calculateChangeList.statesCount && (
<div className='px-0.5'>
<Divider className='m-0' />
<div
className={cn(
'my-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] text-text-secondary',
'hover:bg-state-base-hover',
)}
onClick={() => {
handleClearHistory()
setOpen(false)
}}
>
<div>
<div
className={cn(
'flex items-center text-[13px] font-medium leading-[18px]',
)}
>
{t('workflow.changeHistory.clearHistory')}
</div>
</div>
</div>
</div>
)
}
<div className="w-[240px] px-3 py-2 text-xs text-text-tertiary" >
<div className="mb-1 flex h-[22px] items-center font-medium uppercase">{t('workflow.changeHistory.hint')}</div>
<div className="mb-1 leading-[18px] text-text-tertiary">{t('workflow.changeHistory.hintText')}</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
)
}
export default memo(ViewWorkflowHistory)