dify/web/app/components/workflow/header/view-workflow-history.tsx

324 lines
12 KiB
TypeScript

import type { WorkflowHistoryState } from '../store/workflow/history-slice'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
import { memo, useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import { useStore as useAppStore } from '@/app/components/app/store'
import Divider from '../../base/divider'
import { collaborationManager } from '../collaboration/core/collaboration-manager'
import { useNodesReadOnly, useWorkflowHistory } from '../hooks'
import { useCollaborativeWorkflow } from '../hooks/use-collaborative-workflow'
import TipPopup from '../operator/tip-popup'
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 collaborativeWorkflow = useCollaborativeWorkflow()
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 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
const shouldBroadcast = collaborationManager.isConnected()
const { setEdges, setNodes } = collaborativeWorkflow.getState()
setEdges(edges, shouldBroadcast)
setNodes(nodes, shouldBroadcast, 'history:jump')
if (collaborationManager.isConnected()) collaborationManager.emitHistoryAction('jump')
},
[collaborativeWorkflow, currentHistoryStateIndex, redo, store, undo],
)
const calculateStepLabel = useCallback(
(index: number) => {
if (!index) return
const count = index < 0 ? index * -1 : index
return `${index > 0 ? t(($) => $['changeHistory.stepForward'], { ns: 'workflow', count }) : t(($) => $['changeHistory.stepBackward'], { ns: 'workflow', count })}`
},
[t],
)
const calculateChangeList: ChangeHistoryList = useMemo(() => {
const filterList = (
list: Array<Partial<WorkflowHistoryState> | undefined>,
startIndex = 0,
reverse = false,
) =>
list.flatMap((state, index) => {
if (!state) return []
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,
},
},
]
})
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 (
<Popover
modal="trap-focus"
open={open}
onOpenChange={(nextOpen) => {
if (nodesReadOnly) return
setOpen(nextOpen)
}}
>
<TipPopup title={t(($) => $['changeHistory.title'], { ns: 'workflow' })}>
{/* Tooltip and Popover share data-popup-open on this button, so read Popover state directly. */}
<PopoverTrigger
className={({ open: popoverOpen }) =>
cn(
popoverOpen &&
'bg-state-accent-active text-text-accent hover:bg-state-accent-active hover:text-text-accent',
)
}
render={
<Button
variant="ghost"
size="small"
disabled={nodesReadOnly}
focusableWhenDisabled
aria-label={t(($) => $['changeHistory.title'], { ns: 'workflow' })}
className={cn(
'size-8 p-0 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
nodesReadOnly &&
'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
)}
onClick={() => {
if (nodesReadOnly) return
setCurrentLogItem()
setShowMessageLogModal(false)
}}
/>
}
>
<span aria-hidden className="i-ri-history-line size-4 shrink-0" />
</PopoverTrigger>
</TipPopup>
<PopoverContent
placement="bottom-end"
popupClassName="border-none bg-transparent shadow-none"
>
<div className="flex max-w-90 min-w-[240px] 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(($) => $['changeHistory.title'], { ns: 'workflow' })}
</div>
<PopoverClose
render={
<Button
variant="ghost"
size="small"
aria-label={t(($) => $['operation.close'], { ns: 'common' })}
className="size-6 shrink-0 p-0 text-text-secondary hover:bg-state-base-hover"
>
<span aria-hidden className="i-ri-close-line size-4 text-text-secondary" />
</Button>
}
onClick={() => {
setCurrentLogItem()
setShowMessageLogModal(false)
}}
/>
</div>
<div
className="overflow-y-auto p-2"
style={{
maxHeight: 'calc(1 / 2 * 100vh)',
}}
>
{!calculateChangeList.statesCount && (
<div className="py-12">
<span
aria-hidden
className="mx-auto mb-2 i-ri-history-line block size-8 text-text-tertiary"
/>
<div className="text-center text-[13px] text-text-tertiary">
{t(($) => $['changeHistory.placeholder'], { ns: 'workflow' })}
</div>
</div>
)}
<div className="flex flex-col">
{calculateChangeList.futureStates.map((item: ChangeHistoryEntry) => (
<button
type="button"
key={item?.index}
className={cn(
'mb-0.5 flex w-full cursor-pointer rounded-lg px-2 py-[7px] text-left 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] leading-[18px] font-medium text-text-secondary',
)}
>
{composeHistoryItemLabel(
item?.state?.workflowHistoryEventMeta?.nodeTitle,
item?.label ||
t(($) => $['changeHistory.sessionStart'], { ns: 'workflow' }),
)}{' '}
({calculateStepLabel(item?.index)}
{item?.index === currentHistoryStateIndex &&
t(($) => $['changeHistory.currentState'], { ns: 'workflow' })}
)
</div>
</div>
</button>
))}
{calculateChangeList.pastStates.map((item: ChangeHistoryEntry) => (
<button
type="button"
key={item?.index}
className={cn(
'mb-0.5 flex w-full cursor-pointer rounded-lg px-2 py-[7px] text-left 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] leading-[18px] font-medium text-text-secondary',
)}
>
{composeHistoryItemLabel(
item?.state?.workflowHistoryEventMeta?.nodeTitle,
item?.label ||
t(($) => $['changeHistory.sessionStart'], { ns: 'workflow' }),
)}{' '}
({calculateStepLabel(item?.index)})
</div>
</div>
</button>
))}
</div>
</div>
{!!calculateChangeList.statesCount && (
<div className="px-0.5">
<Divider className="m-0" />
<button
type="button"
className={cn(
'my-0.5 flex w-full cursor-pointer rounded-lg px-2 py-[7px] text-left text-text-secondary',
'hover:bg-state-base-hover',
)}
onClick={() => {
handleClearHistory()
setOpen(false)
}}
>
<div>
<div className={cn('flex items-center text-[13px] leading-[18px] font-medium')}>
{t(($) => $['changeHistory.clearHistory'], { ns: 'workflow' })}
</div>
</div>
</button>
</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(($) => $['changeHistory.hint'], { ns: 'workflow' })}
</div>
<div className="mb-1 leading-[18px] text-text-tertiary">
{t(($) => $['changeHistory.hintText'], { ns: 'workflow' })}
</div>
</div>
</div>
</PopoverContent>
</Popover>
)
}
export default memo(ViewWorkflowHistory)