diff --git a/web/app/components/base/features/feature-choose/index.tsx b/web/app/components/base/features/feature-choose/index.tsx index 0f63a34640..44ab39c810 100644 --- a/web/app/components/base/features/feature-choose/index.tsx +++ b/web/app/components/base/features/feature-choose/index.tsx @@ -9,9 +9,11 @@ import { Plus02 } from '@/app/components/base/icons/src/vender/line/general' type ChooseFeatureProps = { onChange?: OnFeaturesChange + disabled?: boolean } const ChooseFeature = ({ onChange, + disabled, }: ChooseFeatureProps) => { const { t } = useTranslation() const showFeaturesModal = useFeatures(s => s.showFeaturesModal) @@ -19,8 +21,11 @@ const ChooseFeature = ({ return ( <> + ) + } + +
- ) - } - -
- - - + + + ) + } + { + isRestoring && ( +
+ +
+ + +
+ ) + } ) } diff --git a/web/app/components/workflow/header/publish.tsx b/web/app/components/workflow/header/publish.tsx index 5d904ab470..d28cd95ae7 100644 --- a/web/app/components/workflow/header/publish.tsx +++ b/web/app/components/workflow/header/publish.tsx @@ -1,5 +1,6 @@ import { memo, + useCallback, useState, } from 'react' import { useTranslation } from 'react-i18next' @@ -7,6 +8,7 @@ import { useStore, useWorkflowStore, } from '../store' +import { useWorkflow } from '../hooks' import Button from '@/app/components/base/button' import { PortalToFollowElem, @@ -18,8 +20,12 @@ import { useStore as useAppStore } from '@/app/components/app/store' const Publish = () => { const { t } = useTranslation() + const [published, setPublished] = useState(false) const workflowStore = useWorkflowStore() + const { formatTimeFromNow } = useWorkflow() const runningStatus = useStore(s => s.runningStatus) + const draftUpdatedAt = useStore(s => s.draftUpdatedAt) + const publishedAt = useStore(s => s.publishedAt) const [open, setOpen] = useState(false) const handlePublish = async () => { @@ -27,13 +33,34 @@ const Publish = () => { try { const res = await publishWorkflow(`/apps/${appId}/workflows/publish`) - if (res) - workflowStore.setState({ publishedAt: res.created_at }) + if (res) { + setPublished(true) + workflowStore.getState().setPublishedAt(res.created_at) + } } catch (e) { + setPublished(false) } } + const handleRestore = useCallback(() => { + workflowStore.getState().setIsRestoring(true) + setOpen(false) + }, [workflowStore]) + + const handleTrigger = useCallback(() => { + if (runningStatus) + return + + if (open) + setOpen(false) + + if (!open) { + setOpen(true) + setPublished(false) + } + }, [runningStatus, open]) + return ( { crossAxis: -5, }} > - { - if (runningStatus) - return - - setOpen(v => !v) - }}> + -
-
- {t('workflow.common.latestPublished').toLocaleUpperCase()} -
-
-
- {t('workflow.common.autoSaved')} 3 min ago · Evan + { + !!publishedAt && ( +
+
+ {t('workflow.common.latestPublished').toLocaleUpperCase()} +
+
+
+ {t('workflow.common.autoSaved')} {formatTimeFromNow(publishedAt)} +
+ +
- -
-
+ ) + }
diff --git a/web/app/components/workflow/header/restoring-title.tsx b/web/app/components/workflow/header/restoring-title.tsx new file mode 100644 index 0000000000..cdb9040230 --- /dev/null +++ b/web/app/components/workflow/header/restoring-title.tsx @@ -0,0 +1,21 @@ +import { memo } from 'react' +import { useTranslation } from 'react-i18next' +import { useWorkflow } from '../hooks' +import { useStore } from '../store' +import { ClockRefresh } from '@/app/components/base/icons/src/vender/line/time' + +const RestoringTitle = () => { + const { t } = useTranslation() + const { formatTimeFromNow } = useWorkflow() + const publishedAt = useStore(state => state.publishedAt) + + return ( +
+ + {t('workflow.common.latestPublished')} + {formatTimeFromNow(publishedAt)} +
+ ) +} + +export default memo(RestoringTitle) diff --git a/web/app/components/workflow/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow/hooks/use-nodes-sync-draft.ts index dd7319532f..d0f051fd5d 100644 --- a/web/app/components/workflow/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow/hooks/use-nodes-sync-draft.ts @@ -62,7 +62,7 @@ export const useNodesSyncDraft = () => { }, }, }).then((res) => { - workflowStore.setState({ draftUpdatedAt: res.updated_at }) + workflowStore.getState().setDraftUpdatedAt(res.updated_at) }) } }, [store, reactFlow, featuresStore, workflowStore]) @@ -73,9 +73,12 @@ export const useNodesSyncDraft = () => { }) const handleSyncWorkflowDraft = useCallback((shouldDelay?: boolean) => { - const { runningStatus } = workflowStore.getState() + const { + runningStatus, + isRestoring, + } = workflowStore.getState() - if (runningStatus) + if (runningStatus || isRestoring) return if (shouldDelay) diff --git a/web/app/components/workflow/hooks/use-workflow-run.ts b/web/app/components/workflow/hooks/use-workflow-run.ts index 9cf32696ce..93fe47fe10 100644 --- a/web/app/components/workflow/hooks/use-workflow-run.ts +++ b/web/app/components/workflow/hooks/use-workflow-run.ts @@ -12,12 +12,17 @@ import { import { useStore as useAppStore } from '@/app/components/app/store' import type { IOtherOptions } from '@/service/base' import { ssePost } from '@/service/base' -import { stopWorkflowRun } from '@/service/workflow' +import { + fetchPublishedWorkflow, + stopWorkflowRun, +} from '@/service/workflow' +import { useFeaturesStore } from '@/app/components/base/features/hooks' export const useWorkflowRun = () => { const store = useStoreApi() const workflowStore = useWorkflowStore() const reactflow = useReactFlow() + const featuresStore = useFeaturesStore() const handleBackupDraft = useCallback(() => { const { @@ -178,10 +183,34 @@ export const useWorkflowRun = () => { stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`) }, [workflowStore]) + const handleRestoreFromPublishedWorkflow = useCallback(async () => { + const appDetail = useAppStore.getState().appDetail + const publishedWorkflow = await fetchPublishedWorkflow(`/apps/${appDetail?.id}/workflows/publish`) + + if (publishedWorkflow) { + const { + setNodes, + setEdges, + } = store.getState() + const { setViewport } = reactflow + const nodes = publishedWorkflow.graph.nodes + const edges = publishedWorkflow.graph.edges + const viewport = publishedWorkflow.graph.viewport + + setNodes(nodes) + setEdges(edges) + if (viewport) + setViewport(viewport) + featuresStore?.setState({ features: publishedWorkflow.features }) + workflowStore.getState().setPublishedAt(publishedWorkflow.created_at) + } + }, [store, reactflow, featuresStore, workflowStore]) + return { handleBackupDraft, handleRunSetting, handleRun, handleStopRun, + handleRestoreFromPublishedWorkflow, } } diff --git a/web/app/components/workflow/hooks/use-workflow.ts b/web/app/components/workflow/hooks/use-workflow.ts index 6d4a74dbdc..50a44ea18a 100644 --- a/web/app/components/workflow/hooks/use-workflow.ts +++ b/web/app/components/workflow/hooks/use-workflow.ts @@ -2,7 +2,9 @@ import { useCallback, useEffect, } from 'react' +import dayjs from 'dayjs' import { uniqBy } from 'lodash-es' +import { useContext } from 'use-context-selector' import useSWR from 'swr' import produce from 'immer' import { @@ -33,10 +35,12 @@ import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useStore as useAppStore } from '@/app/components/app/store' import { fetchNodesDefaultConfigs, + fetchPublishedWorkflow, fetchWorkflowDraft, syncWorkflowDraft, } from '@/service/workflow' import { fetchCollectionList } from '@/service/tools' +import I18n from '@/context/i18n' export const useIsChatMode = () => { const appDetail = useAppStore(s => s.appDetail) @@ -45,6 +49,7 @@ export const useIsChatMode = () => { } export const useWorkflow = () => { + const { locale } = useContext(I18n) const store = useStoreApi() const reactflow = useReactFlow() const workflowStore = useWorkflowStore() @@ -211,12 +216,17 @@ export const useWorkflow = () => { return true }, [store, nodesExtraData]) + const formatTimeFromNow = useCallback((time: number) => { + return dayjs(time).locale(locale === 'zh-Hans' ? 'zh-cn' : locale).fromNow() + }, [locale]) + return { handleLayout, getTreeLeafNodes, getBeforeNodesInSameBranch, getAfterNodesInSameBranch, isValidConnection, + formatTimeFromNow, } } @@ -226,10 +236,11 @@ export const useWorkflowInit = () => { const appDetail = useAppStore(state => state.appDetail)! const { data, error, mutate } = useSWR(`/apps/${appDetail.id}/workflows/draft`, fetchWorkflowDraft) - const handleFetchPreloadData = async () => { + const handleFetchPreloadData = useCallback(async () => { try { const toolsets = await fetchCollectionList() const nodesDefaultConfigsData = await fetchNodesDefaultConfigs(`/apps/${appDetail?.id}/workflows/default-workflow-block-configs`) + const publishedWorkflow = await fetchPublishedWorkflow(`/apps/${appDetail?.id}/workflows/publish`) workflowStore.setState({ toolsets, @@ -245,19 +256,20 @@ export const useWorkflowInit = () => { return acc }, {} as Record), }) + workflowStore.getState().setPublishedAt(publishedWorkflow?.created_at) } catch (e) { } - } + }, [workflowStore, appDetail]) useEffect(() => { handleFetchPreloadData() - }, []) + }, [handleFetchPreloadData]) useEffect(() => { if (data) - workflowStore.setState({ draftUpdatedAt: data.updated_at }) + workflowStore.getState().setDraftUpdatedAt(data.updated_at) }, [data, workflowStore]) if (error && error.json && !error.bodyUsed && appDetail) { @@ -280,7 +292,7 @@ export const useWorkflowInit = () => { features: {}, }, }).then((res) => { - workflowStore.setState({ draftUpdatedAt: res.updated_at }) + workflowStore.getState().setDraftUpdatedAt(res.updated_at) mutate() }) } diff --git a/web/app/components/workflow/panel/run-history.tsx b/web/app/components/workflow/panel/run-history.tsx index ed1195ef9f..71f313694d 100644 --- a/web/app/components/workflow/panel/run-history.tsx +++ b/web/app/components/workflow/panel/run-history.tsx @@ -1,10 +1,12 @@ import { memo } from 'react' import cn from 'classnames' -import dayjs from 'dayjs' import { useTranslation } from 'react-i18next' import useSWR from 'swr' import { WorkflowRunningStatus } from '../types' -import { useIsChatMode } from '../hooks' +import { + useIsChatMode, + useWorkflow, +} from '../hooks' import { CheckCircle, XClose } from '@/app/components/base/icons/src/vender/line/general' import { AlertCircle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback' import { @@ -19,6 +21,7 @@ const RunHistory = () => { const { t } = useTranslation() const isChatMode = useIsChatMode() const { appDetail, setCurrentLogItem, setShowMessageLogModal } = useAppStore() + const { formatTimeFromNow } = useWorkflow() const workflowStore = useWorkflowStore() const workflowRunId = useRunHistoryStore(state => state.workflowRunId) const { data: runList, isLoading: runListLoading } = useSWR((appDetail && !isChatMode) ? `/apps/${appDetail.id}/workflow-runs` : null, fetchWorkflowRunHistory) @@ -93,7 +96,7 @@ const RunHistory = () => { {`Test ${isChatMode ? 'Chat' : 'Run'}#${item.sequence_number}`}
- {item.created_by_account.name} · {dayjs((item.finished_at || item.created_at) * 1000).fromNow()} + {item.created_by_account.name} · {formatTimeFromNow((item.finished_at || item.created_at) * 1000)}
diff --git a/web/app/components/workflow/store.ts b/web/app/components/workflow/store.ts index c410bccd7d..df97791691 100644 --- a/web/app/components/workflow/store.ts +++ b/web/app/components/workflow/store.ts @@ -1,9 +1,9 @@ +import { useContext } from 'react' import { create, useStore as useZustandStore, } from 'zustand' import type { Viewport } from 'reactflow' -import { useContext } from 'react' import type { HelpLineHorizontalPosition, HelpLineVerticalPosition, @@ -44,6 +44,7 @@ type State = { notInitialWorkflow: boolean nodesDefaultConfigs: Record nodeAnimation: boolean + isRestoring: boolean } type Action = { @@ -66,6 +67,7 @@ type Action = { setNotInitialWorkflow: (notInitialWorkflow: boolean) => void setNodesDefaultConfigs: (nodesDefaultConfigs: Record) => void setNodeAnimation: (nodeAnimation: boolean) => void + setIsRestoring: (isRestoring: boolean) => void } export const createWorkflowStore = () => { @@ -91,9 +93,9 @@ export const createWorkflowStore = () => { toolsMap: {}, setToolsMap: toolsMap => set(() => ({ toolsMap })), draftUpdatedAt: 0, - setDraftUpdatedAt: draftUpdatedAt => set(() => ({ draftUpdatedAt })), + setDraftUpdatedAt: draftUpdatedAt => set(() => ({ draftUpdatedAt: draftUpdatedAt ? draftUpdatedAt * 1000 : 0 })), publishedAt: 0, - setPublishedAt: publishedAt => set(() => ({ publishedAt })), + setPublishedAt: publishedAt => set(() => ({ publishedAt: publishedAt ? publishedAt * 1000 : 0 })), runningStatus: undefined, setRunningStatus: runningStatus => set(() => ({ runningStatus })), showInputsPanel: false, @@ -108,6 +110,8 @@ export const createWorkflowStore = () => { setNodesDefaultConfigs: nodesDefaultConfigs => set(() => ({ nodesDefaultConfigs })), nodeAnimation: false, setNodeAnimation: nodeAnimation => set(() => ({ nodeAnimation })), + isRestoring: false, + setIsRestoring: isRestoring => set(() => ({ isRestoring })), })) } diff --git a/web/service/workflow.ts b/web/service/workflow.ts index 48c915cf7d..6b95213d9c 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -36,6 +36,10 @@ export const publishWorkflow = (url: string) => { return post(url) } +export const fetchPublishedWorkflow: Fetcher = (url) => { + return get(url) +} + export const stopWorkflowRun = (url: string) => { return post(url) } diff --git a/web/types/workflow.ts b/web/types/workflow.ts index d5af8fd1ee..e83626c417 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -40,6 +40,12 @@ export type FetchWorkflowDraftResponse = { viewport?: Viewport } features?: any + created_at: number + created_by: { + id: string + name: string + email: string + } updated_at: number }