| @@ -120,6 +120,7 @@ const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => { | |||
| }).finally(() => { | |||
| setIsLoadingAppDetail(false) | |||
| }) | |||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||
| }, [appId, pathname]) | |||
| useEffect(() => { | |||
| @@ -166,7 +166,7 @@ const ConfigPopup: FC<PopupProps> = ({ | |||
| <div className='flex justify-between items-center'> | |||
| <div className='flex items-center'> | |||
| <TracingIcon size='md' className='mr-2' /> | |||
| <div className='text-text-primary title-2xl-semibold'>{t(`${I18N_PREFIX}.tracing`)}</div> | |||
| <div className='text-text-primary title-2xl-semi-bold'>{t(`${I18N_PREFIX}.tracing`)}</div> | |||
| </div> | |||
| <div className='flex items-center'> | |||
| <Indicator color={enabled ? 'green' : 'gray'} /> | |||
| @@ -172,7 +172,7 @@ const ProviderConfigModal: FC<Props> = ({ | |||
| <div className='mx-2 w-[640px] max-h-[calc(100vh-120px)] bg-components-panel-bg shadow-xl rounded-2xl overflow-y-auto'> | |||
| <div className='px-8 pt-8'> | |||
| <div className='flex justify-between items-center mb-4'> | |||
| <div className='title-2xl-semibold text-text-primary'>{t(`${I18N_PREFIX}.title`)}{t(`app.tracing.${type}.title`)}</div> | |||
| <div className='title-2xl-semi-bold text-text-primary'>{t(`${I18N_PREFIX}.title`)}{t(`app.tracing.${type}.title`)}</div> | |||
| </div> | |||
| <div className='space-y-4'> | |||
| @@ -5,9 +5,17 @@ import { | |||
| } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import dayjs from 'dayjs' | |||
| import { RiArrowDownSLine, RiPlanetLine } from '@remixicon/react' | |||
| import { | |||
| RiArrowDownSLine, | |||
| RiPlanetLine, | |||
| RiPlayCircleLine, | |||
| RiPlayList2Line, | |||
| RiTerminalBoxLine, | |||
| } from '@remixicon/react' | |||
| import { useKeyPress } from 'ahooks' | |||
| import Toast from '../../base/toast' | |||
| import type { ModelAndParameter } from '../configuration/debug/types' | |||
| import { getKeyboardKeyCodeBySystem } from '../../workflow/utils' | |||
| import SuggestedAction from './suggested-action' | |||
| import PublishWithMultipleModel from './publish-with-multiple-model' | |||
| import Button from '@/app/components/base/button' | |||
| @@ -20,13 +28,12 @@ import { fetchInstalledAppList } from '@/service/explore' | |||
| import EmbeddedModal from '@/app/components/app/overview/embedded' | |||
| import { useStore as useAppStore } from '@/app/components/app/store' | |||
| import { useGetLanguage } from '@/context/i18n' | |||
| import { PlayCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices' | |||
| import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development' | |||
| import { LeftIndent02 } from '@/app/components/base/icons/src/vender/line/editor' | |||
| import { FileText } from '@/app/components/base/icons/src/vender/line/files' | |||
| import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button' | |||
| import type { InputVar } from '@/app/components/workflow/types' | |||
| import { appDefaultIconBackground } from '@/config' | |||
| import type { PublishWorkflowParams } from '@/types/workflow' | |||
| import VersionInfoModal from './version-info-modal' | |||
| export type AppPublisherProps = { | |||
| disabled?: boolean | |||
| @@ -37,8 +44,7 @@ export type AppPublisherProps = { | |||
| debugWithMultipleModel?: boolean | |||
| multipleModelConfigs?: ModelAndParameter[] | |||
| /** modelAndParameter is passed when debugWithMultipleModel is true */ | |||
| onPublish?: (modelAndParameter?: ModelAndParameter) => Promise<any> | any | |||
| onRestore?: () => Promise<any> | any | |||
| onPublish?: (params?: any) => Promise<any> | any | |||
| onToggle?: (state: boolean) => void | |||
| crossAxisOffset?: number | |||
| toolPublished?: boolean | |||
| @@ -46,6 +52,8 @@ export type AppPublisherProps = { | |||
| onRefreshData?: () => void | |||
| } | |||
| const PUBLISH_SHORTCUT = ['⌘', '⇧', 'P'] | |||
| const AppPublisher = ({ | |||
| disabled = false, | |||
| publishDisabled = false, | |||
| @@ -54,7 +62,6 @@ const AppPublisher = ({ | |||
| debugWithMultipleModel = false, | |||
| multipleModelConfigs = [], | |||
| onPublish, | |||
| onRestore, | |||
| onToggle, | |||
| crossAxisOffset = 0, | |||
| toolPublished, | |||
| @@ -64,6 +71,7 @@ const AppPublisher = ({ | |||
| const { t } = useTranslation() | |||
| const [published, setPublished] = useState(false) | |||
| const [open, setOpen] = useState(false) | |||
| const [publishModalOpen, setPublishModalOpen] = useState(false) | |||
| const appDetail = useAppStore(state => state.appDetail) | |||
| const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {} | |||
| const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode | |||
| @@ -74,24 +82,16 @@ const AppPublisher = ({ | |||
| return dayjs(time).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow() | |||
| }, [language]) | |||
| const handlePublish = async (modelAndParameter?: ModelAndParameter) => { | |||
| const handlePublish = async (params?: ModelAndParameter | PublishWorkflowParams) => { | |||
| try { | |||
| await onPublish?.(modelAndParameter) | |||
| await onPublish?.(params) | |||
| setPublished(true) | |||
| } | |||
| catch (e) { | |||
| catch { | |||
| setPublished(false) | |||
| } | |||
| } | |||
| const handleRestore = useCallback(async () => { | |||
| try { | |||
| await onRestore?.() | |||
| setOpen(false) | |||
| } | |||
| catch (e) { } | |||
| }, [onRestore]) | |||
| const handleTrigger = useCallback(() => { | |||
| const state = !open | |||
| @@ -122,139 +122,178 @@ const AppPublisher = ({ | |||
| const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) | |||
| const openPublishModal = () => { | |||
| setOpen(false) | |||
| setPublishModalOpen(true) | |||
| } | |||
| const closePublishModal = () => { | |||
| setPublishModalOpen(false) | |||
| } | |||
| useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { | |||
| e.preventDefault() | |||
| if (publishDisabled || published) | |||
| return | |||
| openPublishModal() | |||
| } | |||
| , { exactMatch: true, useCapture: true }) | |||
| return ( | |||
| <PortalToFollowElem | |||
| open={open} | |||
| onOpenChange={setOpen} | |||
| placement='bottom-end' | |||
| offset={{ | |||
| mainAxis: 4, | |||
| crossAxis: crossAxisOffset, | |||
| }} | |||
| > | |||
| <PortalToFollowElemTrigger onClick={handleTrigger}> | |||
| <Button | |||
| variant='primary' | |||
| className='pl-3 pr-2' | |||
| disabled={disabled} | |||
| > | |||
| {t('workflow.common.publish')} | |||
| <RiArrowDownSLine className='w-4 h-4 ml-0.5' /> | |||
| </Button> | |||
| </PortalToFollowElemTrigger> | |||
| <PortalToFollowElemContent className='z-[11]'> | |||
| <div className='w-[336px] bg-components-panel-bg rounded-2xl border-[0.5px] border-components-panel-border shadow-xl'> | |||
| <div className='p-4 pt-3'> | |||
| <div className='flex items-center h-6 system-xs-medium-uppercase text-text-tertiary'> | |||
| {publishedAt ? t('workflow.common.latestPublished') : t('workflow.common.currentDraftUnpublished')} | |||
| </div> | |||
| {publishedAt | |||
| ? ( | |||
| <div className='flex justify-between items-center h-[18px]'> | |||
| <div className='flex items-center mt-[3px] mb-[3px] leading-[18px] text-[13px] font-medium text-text-secondary'> | |||
| <> | |||
| <PortalToFollowElem | |||
| open={open} | |||
| onOpenChange={setOpen} | |||
| placement='bottom-end' | |||
| offset={{ | |||
| mainAxis: 4, | |||
| crossAxis: crossAxisOffset, | |||
| }} | |||
| > | |||
| <PortalToFollowElemTrigger onClick={handleTrigger}> | |||
| <Button | |||
| variant='primary' | |||
| className='p-2' | |||
| disabled={disabled} | |||
| > | |||
| {t('workflow.common.publish')} | |||
| <RiArrowDownSLine className='w-4 h-4 text-components-button-primary-text' /> | |||
| </Button> | |||
| </PortalToFollowElemTrigger> | |||
| <PortalToFollowElemContent className='z-[11]'> | |||
| <div className='w-[320px] bg-components-panel-bg rounded-2xl border-[0.5px] border-components-panel-border shadow-xl shadow-shadow-shadow-5'> | |||
| <div className='p-4 pt-3'> | |||
| <div className='flex items-center h-6 system-xs-medium-uppercase text-text-tertiary'> | |||
| {publishedAt ? t('workflow.common.latestPublished') : t('workflow.common.currentDraftUnpublished')} | |||
| </div> | |||
| {publishedAt | |||
| ? ( | |||
| <div className='flex items-center system-sm-medium text-text-secondary'> | |||
| {t('workflow.common.publishedAt')} {formatTimeFromNow(publishedAt)} | |||
| </div> | |||
| ) | |||
| : ( | |||
| <div className='flex items-center system-sm-medium text-text-secondary'> | |||
| {t('workflow.common.autoSaved')} · {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} | |||
| </div> | |||
| )} | |||
| {debugWithMultipleModel | |||
| ? ( | |||
| <PublishWithMultipleModel | |||
| multipleModelConfigs={multipleModelConfigs} | |||
| onSelect={item => handlePublish(item)} | |||
| // textGenerationModelList={textGenerationModelList} | |||
| /> | |||
| ) | |||
| : ( | |||
| <Button | |||
| variant='secondary-accent' | |||
| size='small' | |||
| onClick={handleRestore} | |||
| disabled={published} | |||
| variant='primary' | |||
| className='w-full mt-3' | |||
| onClick={openPublishModal} | |||
| disabled={publishDisabled || published} | |||
| > | |||
| {t('workflow.common.restore')} | |||
| { | |||
| published | |||
| ? t('workflow.common.published') | |||
| : ( | |||
| <div className='flex gap-1'> | |||
| <span>{t('workflow.common.publishUpdate')}</span> | |||
| <div className='flex gap-0.5'> | |||
| {PUBLISH_SHORTCUT.map(key => ( | |||
| <span key={key} className='w-4 h-4 text-text-primary-on-surface system-kbd rounded-[4px] bg-components-kbd-bg-white'> | |||
| {key} | |||
| </span> | |||
| ))} | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| </Button> | |||
| </div> | |||
| ) | |||
| : ( | |||
| <div className='flex items-center h-[18px] leading-[18px] text-[13px] font-medium text-text-secondary'> | |||
| {t('workflow.common.autoSaved')} · {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} | |||
| </div> | |||
| )} | |||
| {debugWithMultipleModel | |||
| ? ( | |||
| <PublishWithMultipleModel | |||
| multipleModelConfigs={multipleModelConfigs} | |||
| onSelect={item => handlePublish(item)} | |||
| // textGenerationModelList={textGenerationModelList} | |||
| /> | |||
| ) | |||
| : ( | |||
| <Button | |||
| variant='primary' | |||
| className='w-full mt-3' | |||
| onClick={() => handlePublish()} | |||
| disabled={publishDisabled || published} | |||
| > | |||
| { | |||
| published | |||
| ? t('workflow.common.published') | |||
| : publishedAt ? t('workflow.common.update') : t('workflow.common.publish') | |||
| } | |||
| </Button> | |||
| ) | |||
| } | |||
| </div> | |||
| <div className='p-4 pt-3 border-t-[0.5px] border-divider-regular'> | |||
| <SuggestedAction disabled={!publishedAt} link={appURL} icon={<PlayCircle />}>{t('workflow.common.runApp')}</SuggestedAction> | |||
| {appDetail?.mode === 'workflow' | |||
| ? ( | |||
| <SuggestedAction | |||
| ) | |||
| } | |||
| </div> | |||
| <div className='p-4 pt-3 border-t-[0.5px] border-t-divider-regular'> | |||
| <SuggestedAction | |||
| disabled={!publishedAt} | |||
| link={appURL} | |||
| icon={<RiPlayCircleLine className='w-4 h-4' />} | |||
| > | |||
| {t('workflow.common.runApp')} | |||
| </SuggestedAction> | |||
| {appDetail?.mode === 'workflow' | |||
| ? ( | |||
| <SuggestedAction | |||
| disabled={!publishedAt} | |||
| link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`} | |||
| icon={<RiPlayList2Line className='w-4 h-4' />} | |||
| > | |||
| {t('workflow.common.batchRunApp')} | |||
| </SuggestedAction> | |||
| ) | |||
| : ( | |||
| <SuggestedAction | |||
| onClick={() => { | |||
| setEmbeddingModalOpen(true) | |||
| handleTrigger() | |||
| }} | |||
| disabled={!publishedAt} | |||
| icon={<CodeBrowser className='w-4 h-4' />} | |||
| > | |||
| {t('workflow.common.embedIntoSite')} | |||
| </SuggestedAction> | |||
| )} | |||
| <SuggestedAction | |||
| onClick={() => { | |||
| publishedAt && handleOpenInExplore() | |||
| }} | |||
| disabled={!publishedAt} | |||
| icon={<RiPlanetLine className='w-4 h-4' />} | |||
| > | |||
| {t('workflow.common.openInExplore')} | |||
| </SuggestedAction> | |||
| <SuggestedAction | |||
| disabled={!publishedAt} | |||
| link='./develop' | |||
| icon={<RiTerminalBoxLine className='w-4 h-4' />} | |||
| > | |||
| {t('workflow.common.accessAPIReference')} | |||
| </SuggestedAction> | |||
| {appDetail?.mode === 'workflow' && ( | |||
| <WorkflowToolConfigureButton | |||
| disabled={!publishedAt} | |||
| link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`} | |||
| icon={<LeftIndent02 className='w-4 h-4' />} | |||
| > | |||
| {t('workflow.common.batchRunApp')} | |||
| </SuggestedAction> | |||
| ) | |||
| : ( | |||
| <SuggestedAction | |||
| onClick={() => { | |||
| setEmbeddingModalOpen(true) | |||
| handleTrigger() | |||
| published={!!toolPublished} | |||
| detailNeedUpdate={!!toolPublished && published} | |||
| workflowAppId={appDetail?.id} | |||
| icon={{ | |||
| content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖', | |||
| background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground, | |||
| }} | |||
| disabled={!publishedAt} | |||
| icon={<CodeBrowser className='w-4 h-4' />} | |||
| > | |||
| {t('workflow.common.embedIntoSite')} | |||
| </SuggestedAction> | |||
| name={appDetail?.name} | |||
| description={appDetail?.description} | |||
| inputs={inputs} | |||
| handlePublish={handlePublish} | |||
| onRefreshData={onRefreshData} | |||
| /> | |||
| )} | |||
| <SuggestedAction | |||
| onClick={() => { | |||
| publishedAt && handleOpenInExplore() | |||
| }} | |||
| disabled={!publishedAt} | |||
| icon={<RiPlanetLine className='w-4 h-4' />} | |||
| > | |||
| {t('workflow.common.openInExplore')} | |||
| </SuggestedAction> | |||
| <SuggestedAction disabled={!publishedAt} link='./develop' icon={<FileText className='w-4 h-4' />}>{t('workflow.common.accessAPIReference')}</SuggestedAction> | |||
| {appDetail?.mode === 'workflow' && ( | |||
| <WorkflowToolConfigureButton | |||
| disabled={!publishedAt} | |||
| published={!!toolPublished} | |||
| detailNeedUpdate={!!toolPublished && published} | |||
| workflowAppId={appDetail?.id} | |||
| icon={{ | |||
| content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖', | |||
| background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground, | |||
| }} | |||
| name={appDetail?.name} | |||
| description={appDetail?.description} | |||
| inputs={inputs} | |||
| handlePublish={handlePublish} | |||
| onRefreshData={onRefreshData} | |||
| /> | |||
| )} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| </PortalToFollowElemContent> | |||
| <EmbeddedModal | |||
| siteInfo={appDetail?.site} | |||
| isShow={embeddingModalOpen} | |||
| onClose={() => setEmbeddingModalOpen(false)} | |||
| appBaseUrl={appBaseURL} | |||
| accessToken={accessToken} | |||
| /> | |||
| </PortalToFollowElem > | |||
| </PortalToFollowElemContent> | |||
| <EmbeddedModal | |||
| siteInfo={appDetail?.site} | |||
| isShow={embeddingModalOpen} | |||
| onClose={() => setEmbeddingModalOpen(false)} | |||
| appBaseUrl={appBaseURL} | |||
| accessToken={accessToken} | |||
| /> | |||
| </PortalToFollowElem > | |||
| {publishModalOpen && ( | |||
| <VersionInfoModal | |||
| isOpen={publishModalOpen} | |||
| onClose={closePublishModal} | |||
| onPublish={handlePublish} | |||
| /> | |||
| )} | |||
| </> | |||
| ) | |||
| } | |||
| @@ -1,6 +1,6 @@ | |||
| import type { HTMLProps, PropsWithChildren } from 'react' | |||
| import { RiArrowRightUpLine } from '@remixicon/react' | |||
| import classNames from '@/utils/classnames' | |||
| import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows' | |||
| export type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement> & { | |||
| icon?: React.ReactNode | |||
| @@ -14,15 +14,15 @@ const SuggestedAction = ({ icon, link, disabled, children, className, ...props } | |||
| target='_blank' | |||
| rel='noreferrer' | |||
| className={classNames( | |||
| 'flex justify-start items-center gap-2 h-[34px] px-2.5 bg-background-section-burn rounded-lg transition-colors text-text-secondary [&:not(:first-child)]:mt-1', | |||
| disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'hover:bg-state-accent-hover hover:text-text-accent cursor-pointer', | |||
| 'flex justify-start items-center gap-2 py-2 px-2.5 bg-background-section-burn rounded-lg transition-colors [&:not(:first-child)]:mt-1', | |||
| disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'text-text-secondary hover:bg-state-accent-hover hover:text-text-accent cursor-pointer', | |||
| className, | |||
| )} | |||
| {...props} | |||
| > | |||
| <div className='relative w-4 h-4'>{icon}</div> | |||
| <div className='grow shrink basis-0 text-[13px] font-medium leading-[18px]'>{children}</div> | |||
| <ArrowUpRight /> | |||
| <div className='grow shrink basis-0 system-sm-medium'>{children}</div> | |||
| <RiArrowRightUpLine className='w-3.5 h-3.5' /> | |||
| </a> | |||
| ) | |||
| @@ -0,0 +1,112 @@ | |||
| import React, { type FC, useCallback, useState } from 'react' | |||
| import Modal from '@/app/components/base/modal' | |||
| import type { VersionHistory } from '@/types/workflow' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { RiCloseLine } from '@remixicon/react' | |||
| import Input from '../../base/input' | |||
| import Textarea from '../../base/textarea' | |||
| import Button from '../../base/button' | |||
| import Toast from '@/app/components/base/toast' | |||
| type VersionInfoModalProps = { | |||
| isOpen: boolean | |||
| versionInfo?: VersionHistory | |||
| onClose: () => void | |||
| onPublish: (params: { title: string; releaseNotes: string; id?: string }) => void | |||
| } | |||
| const TITLE_MAX_LENGTH = 15 | |||
| const RELEASE_NOTES_MAX_LENGTH = 100 | |||
| const VersionInfoModal: FC<VersionInfoModalProps> = ({ | |||
| isOpen, | |||
| versionInfo, | |||
| onClose, | |||
| onPublish, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| const [title, setTitle] = useState(versionInfo?.marked_name || '') | |||
| const [releaseNotes, setReleaseNotes] = useState(versionInfo?.marked_comment || '') | |||
| const [titleError, setTitleError] = useState(false) | |||
| const [releaseNotesError, setReleaseNotesError] = useState(false) | |||
| const handlePublish = () => { | |||
| if (title.length > TITLE_MAX_LENGTH) { | |||
| setTitleError(true) | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('workflow.versionHistory.editField.titleLengthLimit', { limit: TITLE_MAX_LENGTH }), | |||
| }) | |||
| return | |||
| } | |||
| else { | |||
| titleError && setTitleError(false) | |||
| } | |||
| if (releaseNotes.length > RELEASE_NOTES_MAX_LENGTH) { | |||
| setReleaseNotesError(true) | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('workflow.versionHistory.editField.releaseNotesLengthLimit', { limit: RELEASE_NOTES_MAX_LENGTH }), | |||
| }) | |||
| return | |||
| } | |||
| else { | |||
| releaseNotesError && setReleaseNotesError(false) | |||
| } | |||
| onPublish({ title, releaseNotes, id: versionInfo?.id }) | |||
| onClose() | |||
| } | |||
| const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { | |||
| setTitle(e.target.value) | |||
| }, []) | |||
| const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => { | |||
| setReleaseNotes(e.target.value) | |||
| }, []) | |||
| return <Modal className='p-0' isShow={isOpen} onClose={onClose}> | |||
| <div className='relative w-full p-6 pb-4 pr-14'> | |||
| <div className='text-text-primary title-2xl-semi-bold first-letter:capitalize'> | |||
| {versionInfo?.marked_name ? t('workflow.versionHistory.editVersionInfo') : t('workflow.versionHistory.nameThisVersion')} | |||
| </div> | |||
| <div className='w-8 h-8 flex items-center justify-center p-1.5 absolute top-5 right-5 cursor-pointer' onClick={onClose}> | |||
| <RiCloseLine className='w-[18px] h-[18px] text-text-tertiary' /> | |||
| </div> | |||
| </div> | |||
| <div className='flex flex-col gap-y-4 px-6 py-3'> | |||
| <div className='flex flex-col gap-y-1'> | |||
| <div className='flex items-center h-6 text-text-secondary system-sm-semibold'> | |||
| {t('workflow.versionHistory.editField.title')} | |||
| </div> | |||
| <Input | |||
| value={title} | |||
| placeholder={t('workflow.versionHistory.nameThisVersion')} | |||
| onChange={handleTitleChange} | |||
| destructive={titleError} | |||
| /> | |||
| </div> | |||
| <div className='flex flex-col gap-y-1'> | |||
| <div className='flex items-center h-6 text-text-secondary system-sm-semibold'> | |||
| {t('workflow.versionHistory.editField.releaseNotes')} | |||
| </div> | |||
| <Textarea | |||
| value={releaseNotes} | |||
| placeholder={t('workflow.versionHistory.releaseNotesPlaceholder')} | |||
| onChange={handleDescriptionChange} | |||
| destructive={releaseNotesError} | |||
| /> | |||
| </div> | |||
| </div> | |||
| <div className='flex justify-end p-6 pt-5'> | |||
| <div className='flex items-center gap-x-3'> | |||
| <Button onClick={onClose}>{t('common.operation.cancel')}</Button> | |||
| <Button variant='primary' onClick={handlePublish}>{t('workflow.common.publish')}</Button> | |||
| </div> | |||
| </div> | |||
| </Modal> | |||
| } | |||
| export default VersionInfoModal | |||
| @@ -79,7 +79,7 @@ const ConfigParamModal: FC<Props> = ({ | |||
| onClose={onHide} | |||
| className='!p-6 !mt-14 !max-w-none !w-[640px]' | |||
| > | |||
| <div className='mb-2 title-2xl-semibold text-text-primary'> | |||
| <div className='mb-2 title-2xl-semi-bold text-text-primary'> | |||
| {t(`appAnnotation.initSetup.${isInit ? 'title' : 'configTitle'}`)} | |||
| </div> | |||
| @@ -2,10 +2,10 @@ | |||
| import React, { useCallback, useEffect, useMemo, useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useRouter } from 'next/navigation' | |||
| import { RiArrowRightUpLine, RiHammerLine } from '@remixicon/react' | |||
| import Divider from '../../base/divider' | |||
| import cn from '@/utils/classnames' | |||
| import Button from '@/app/components/base/button' | |||
| import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows' | |||
| import { Tools } from '@/app/components/base/icons/src/vender/line/others' | |||
| import Indicator from '@/app/components/header/indicator' | |||
| import WorkflowToolModal from '@/app/components/tools/workflow-tool' | |||
| import Loading from '@/app/components/base/loading' | |||
| @@ -13,6 +13,7 @@ import Toast from '@/app/components/base/toast' | |||
| import { createWorkflowToolProvider, fetchWorkflowToolDetailByAppID, saveWorkflowToolProvider } from '@/service/tools' | |||
| import type { Emoji, WorkflowToolProviderParameter, WorkflowToolProviderRequest, WorkflowToolProviderResponse } from '@/app/components/tools/types' | |||
| import type { InputVar } from '@/app/components/workflow/types' | |||
| import type { PublishWorkflowParams } from '@/types/workflow' | |||
| import { useAppContext } from '@/context/app-context' | |||
| import { useInvalidateAllWorkflowTools } from '@/service/use-tools' | |||
| @@ -25,7 +26,7 @@ type Props = { | |||
| name: string | |||
| description: string | |||
| inputs?: InputVar[] | |||
| handlePublish: () => void | |||
| handlePublish: (params?: PublishWorkflowParams) => Promise<void> | |||
| onRefreshData?: () => void | |||
| } | |||
| @@ -174,61 +175,74 @@ const WorkflowToolConfigureButton = ({ | |||
| return ( | |||
| <> | |||
| <div className='mt-2 pt-2 border-t-[0.5px] border-divider-regular'> | |||
| {(!published || !isLoading) && ( | |||
| <div className={cn( | |||
| 'group bg-background-section-burn rounded-lg transition-colors', | |||
| disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'cursor-pointer', | |||
| !disabled && !published && 'hover:bg-primary-50', | |||
| )}> | |||
| {isCurrentWorkspaceManager | |||
| ? ( | |||
| <Divider type='horizontal' className='h-[1px] bg-divider-subtle' /> | |||
| {(!published || !isLoading) && ( | |||
| <div className={cn( | |||
| 'group bg-background-section-burn rounded-lg transition-colors', | |||
| disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'cursor-pointer', | |||
| !disabled && !published && 'hover:bg-state-accent-hover', | |||
| )}> | |||
| {isCurrentWorkspaceManager | |||
| ? ( | |||
| <div | |||
| className='flex justify-start items-center gap-2 p-2 pl-2.5' | |||
| onClick={() => !disabled && !published && setShowModal(true)} | |||
| > | |||
| <RiHammerLine className={cn('relative w-4 h-4 text-text-secondary', !disabled && !published && 'group-hover:text-text-accent')} /> | |||
| <div | |||
| className='flex justify-start items-center text-text-primary gap-2 px-2.5 py-2' | |||
| onClick={() => !disabled && !published && setShowModal(true)} | |||
| title={t('workflow.common.workflowAsTool') || ''} | |||
| className={cn('grow shrink basis-0 system-sm-medium text-text-secondary truncate', !disabled && !published && 'group-hover:text-text-accent')} | |||
| > | |||
| <Tools className={cn('relative w-4 h-4', !disabled && !published && 'group-hover:text-primary-600')} /> | |||
| <div title={t('workflow.common.workflowAsTool') || ''} className={cn('grow shrink basis-0 text-[13px] font-medium leading-[18px] truncate', !disabled && !published && 'group-hover:text-primary-600')}>{t('workflow.common.workflowAsTool')}</div> | |||
| {!published && ( | |||
| <span className='shrink-0 px-1 border border-divider-regular rounded-[5px] bg-background-default-subtle text-[10px] font-medium leading-[18px] text-text-tertiary'>{t('workflow.common.configureRequired').toLocaleUpperCase()}</span> | |||
| )} | |||
| </div>) | |||
| : ( | |||
| {t('workflow.common.workflowAsTool')} | |||
| </div> | |||
| {!published && ( | |||
| <span className='shrink-0 px-1 py-0.5 border border-divider-deep rounded-[5px] bg-components-badge-bg-dimm system-2xs-medium-uppercase text-text-tertiary'> | |||
| {t('workflow.common.configureRequired')} | |||
| </span> | |||
| )} | |||
| </div>) | |||
| : ( | |||
| <div | |||
| className='flex justify-start items-center gap-2 p-2 pl-2.5' | |||
| > | |||
| <RiHammerLine className='w-4 h-4 text-text-tertiary' /> | |||
| <div | |||
| className='flex justify-start items-center gap-2 px-2.5 py-2' | |||
| title={t('workflow.common.workflowAsTool') || ''} | |||
| className='grow shrink basis-0 system-sm-medium truncate text-text-tertiary' | |||
| > | |||
| <Tools className='w-4 h-4 text-text-tertiary' /> | |||
| <div title={t('workflow.common.workflowAsTool') || ''} className='grow shrink basis-0 text-[13px] font-medium leading-[18px] truncate text-text-tertiary'>{t('workflow.common.workflowAsTool')}</div> | |||
| </div> | |||
| )} | |||
| {published && ( | |||
| <div className='px-2.5 py-2 border-t-[0.5px] border-divider-regular'> | |||
| <div className='flex justify-between'> | |||
| <Button | |||
| size='small' | |||
| className='w-[140px]' | |||
| onClick={() => setShowModal(true)} | |||
| disabled={!isCurrentWorkspaceManager} | |||
| > | |||
| {t('workflow.common.configure')} | |||
| {outdated && <Indicator className='ml-1' color={'yellow'} />} | |||
| </Button> | |||
| <Button | |||
| size='small' | |||
| className='w-[140px]' | |||
| onClick={() => router.push('/tools?category=workflow')} | |||
| > | |||
| {t('workflow.common.manageInTools')} | |||
| <ArrowUpRight className='ml-1' /> | |||
| </Button> | |||
| {t('workflow.common.workflowAsTool')} | |||
| </div> | |||
| {outdated && <div className='mt-1 text-xs leading-[18px] text-[#dc6803]'>{t('workflow.common.workflowAsToolTip')}</div>} | |||
| </div> | |||
| )} | |||
| </div> | |||
| )} | |||
| {published && isLoading && <div className='pt-2'><Loading type='app' /></div>} | |||
| </div> | |||
| {published && ( | |||
| <div className='px-2.5 py-2 border-t-[0.5px] border-divider-regular'> | |||
| <div className='flex justify-between gap-x-2'> | |||
| <Button | |||
| size='small' | |||
| className='w-[140px]' | |||
| onClick={() => setShowModal(true)} | |||
| disabled={!isCurrentWorkspaceManager} | |||
| > | |||
| {t('workflow.common.configure')} | |||
| {outdated && <Indicator className='ml-1' color={'yellow'} />} | |||
| </Button> | |||
| <Button | |||
| size='small' | |||
| className='w-[140px]' | |||
| onClick={() => router.push('/tools?category=workflow')} | |||
| > | |||
| {t('workflow.common.manageInTools')} | |||
| <RiArrowRightUpLine className='ml-1 w-4 h-4' /> | |||
| </Button> | |||
| </div> | |||
| {outdated && <div className='mt-1 text-xs leading-[18px] text-text-warning'> | |||
| {t('workflow.common.workflowAsToolTip')} | |||
| </div>} | |||
| </div> | |||
| )} | |||
| </div> | |||
| )} | |||
| {published && isLoading && <div className='pt-2'><Loading type='app' /></div>} | |||
| {showModal && ( | |||
| <WorkflowToolModal | |||
| isAdd={!published} | |||
| @@ -4,10 +4,10 @@ import { | |||
| useCallback, | |||
| useMemo, | |||
| } from 'react' | |||
| import { RiApps2AddLine } from '@remixicon/react' | |||
| import { RiApps2AddLine, RiHistoryLine } from '@remixicon/react' | |||
| import { useNodes } from 'reactflow' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useContext } from 'use-context-selector' | |||
| import { useContext, useContextSelector } from 'use-context-selector' | |||
| import { | |||
| useStore, | |||
| useWorkflowStore, | |||
| @@ -15,6 +15,7 @@ import { | |||
| import { | |||
| BlockEnum, | |||
| InputVarType, | |||
| WorkflowVersion, | |||
| } from '../types' | |||
| import type { StartNodeType } from '../nodes/start/types' | |||
| import { | |||
| @@ -27,7 +28,7 @@ import { | |||
| useWorkflowRun, | |||
| } from '../hooks' | |||
| import AppPublisher from '../../app/app-publisher' | |||
| import { ToastContext } from '../../base/toast' | |||
| import Toast, { ToastContext } from '../../base/toast' | |||
| import Divider from '../../base/divider' | |||
| import RunAndHistory from './run-and-history' | |||
| import EditingTitle from './editing-title' | |||
| @@ -36,18 +37,22 @@ import RestoringTitle from './restoring-title' | |||
| import ViewHistory from './view-history' | |||
| import ChatVariableButton from './chat-variable-button' | |||
| import EnvButton from './env-button' | |||
| import VersionHistoryModal from './version-history-modal' | |||
| import VersionHistoryButton from './version-history-button' | |||
| import Button from '@/app/components/base/button' | |||
| import { useStore as useAppStore } from '@/app/components/app/store' | |||
| import { publishWorkflow } from '@/service/workflow' | |||
| import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows' | |||
| import { useFeatures } from '@/app/components/base/features/hooks' | |||
| import { usePublishWorkflow, useResetWorkflowVersionHistory } from '@/service/use-workflow' | |||
| import type { PublishWorkflowParams } from '@/types/workflow' | |||
| import { fetchAppDetail, fetchAppSSO } from '@/service/apps' | |||
| import AppContext from '@/context/app-context' | |||
| const Header: FC = () => { | |||
| const { t } = useTranslation() | |||
| const workflowStore = useWorkflowStore() | |||
| const appDetail = useAppStore(s => s.appDetail) | |||
| const appSidebarExpand = useAppStore(s => s.appSidebarExpand) | |||
| const setAppDetail = useAppStore(s => s.setAppDetail) | |||
| const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures) | |||
| const appID = appDetail?.id | |||
| const isChatMode = useIsChatMode() | |||
| const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly() | |||
| @@ -55,6 +60,10 @@ const Header: FC = () => { | |||
| const publishedAt = useStore(s => s.publishedAt) | |||
| const draftUpdatedAt = useStore(s => s.draftUpdatedAt) | |||
| const toolPublished = useStore(s => s.toolPublished) | |||
| const currentVersion = useStore(s => s.currentVersion) | |||
| const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel) | |||
| const setShowEnvPanel = useStore(s => s.setShowEnvPanel) | |||
| const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel) | |||
| const nodes = useNodes<StartNodeType>() | |||
| const startNode = nodes.find(node => node.data.type === BlockEnum.Start) | |||
| const selectedNode = nodes.find(node => node.data.selected) | |||
| @@ -104,27 +113,70 @@ const Header: FC = () => { | |||
| const handleCancelRestore = useCallback(() => { | |||
| handleLoadBackupDraft() | |||
| workflowStore.setState({ isRestoring: false }) | |||
| }, [workflowStore, handleLoadBackupDraft]) | |||
| setShowWorkflowVersionHistoryPanel(false) | |||
| }, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel]) | |||
| const resetWorkflowVersionHistory = useResetWorkflowVersionHistory(appDetail!.id) | |||
| const handleRestore = useCallback(() => { | |||
| setShowWorkflowVersionHistoryPanel(false) | |||
| workflowStore.setState({ isRestoring: false }) | |||
| workflowStore.setState({ backupDraft: undefined }) | |||
| handleSyncWorkflowDraft(true) | |||
| }, [handleSyncWorkflowDraft, workflowStore]) | |||
| 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: () => { | |||
| resetWorkflowVersionHistory() | |||
| }, | |||
| }) | |||
| }, [handleSyncWorkflowDraft, workflowStore, setShowWorkflowVersionHistoryPanel, resetWorkflowVersionHistory, t]) | |||
| const onPublish = useCallback(async () => { | |||
| const updateAppDetail = useCallback(async () => { | |||
| try { | |||
| const res = await fetchAppDetail({ url: '/apps', id: appID! }) | |||
| if (systemFeatures.enable_web_sso_switch_component) { | |||
| const ssoRes = await fetchAppSSO({ appId: appID! }) | |||
| setAppDetail({ ...res, enable_sso: ssoRes.enabled }) | |||
| } | |||
| else { | |||
| setAppDetail({ ...res }) | |||
| } | |||
| } | |||
| catch (error) { | |||
| console.error(error) | |||
| } | |||
| }, [appID, setAppDetail, systemFeatures.enable_web_sso_switch_component]) | |||
| const { mutateAsync: publishWorkflow } = usePublishWorkflow(appID!) | |||
| const onPublish = useCallback(async (params?: PublishWorkflowParams) => { | |||
| if (handleCheckBeforePublish()) { | |||
| const res = await publishWorkflow(`/apps/${appID}/workflows/publish`) | |||
| const res = await publishWorkflow({ | |||
| title: params?.title || '', | |||
| releaseNotes: params?.releaseNotes || '', | |||
| }) | |||
| if (res) { | |||
| notify({ type: 'success', message: t('common.api.actionSuccess') }) | |||
| updateAppDetail() | |||
| workflowStore.getState().setPublishedAt(res.created_at) | |||
| resetWorkflowVersionHistory() | |||
| } | |||
| } | |||
| else { | |||
| throw new Error('Checklist failed') | |||
| } | |||
| }, [appID, handleCheckBeforePublish, notify, t, workflowStore]) | |||
| }, [handleCheckBeforePublish, notify, t, workflowStore, publishWorkflow, resetWorkflowVersionHistory, updateAppDetail]) | |||
| const onStartRestoring = useCallback(() => { | |||
| workflowStore.setState({ isRestoring: true }) | |||
| @@ -132,7 +184,11 @@ const Header: FC = () => { | |||
| // clear right panel | |||
| if (selectedNode) | |||
| handleNodeSelect(selectedNode.id, true) | |||
| }, [handleBackupDraft, workflowStore, handleNodeSelect, selectedNode]) | |||
| setShowWorkflowVersionHistoryPanel(true) | |||
| setShowEnvPanel(false) | |||
| setShowDebugAndPreviewPanel(false) | |||
| }, [handleBackupDraft, workflowStore, handleNodeSelect, selectedNode, | |||
| setShowWorkflowVersionHistoryPanel, setShowEnvPanel, setShowDebugAndPreviewPanel]) | |||
| const onPublisherToggle = useCallback((state: boolean) => { | |||
| if (state) | |||
| @@ -153,11 +209,6 @@ const Header: FC = () => { | |||
| className='absolute top-0 left-0 z-10 flex items-center justify-between w-full px-3 h-14 bg-mask-top2bottom-gray-50-to-transparent' | |||
| > | |||
| <div> | |||
| { | |||
| appSidebarExpand === 'collapse' && ( | |||
| <div className='system-xs-regular text-text-tertiary'>{appDetail?.name}</div> | |||
| ) | |||
| } | |||
| { | |||
| normal && <EditingTitle /> | |||
| } | |||
| @@ -189,11 +240,11 @@ const Header: FC = () => { | |||
| inputs: variables, | |||
| onRefreshData: handleToolConfigureUpdate, | |||
| onPublish, | |||
| onRestore: onStartRestoring, | |||
| onToggle: onPublisherToggle, | |||
| crossAxisOffset: 4, | |||
| }} | |||
| /> | |||
| <VersionHistoryButton onClick={onStartRestoring} /> | |||
| </div> | |||
| ) | |||
| } | |||
| @@ -214,27 +265,23 @@ const Header: FC = () => { | |||
| } | |||
| { | |||
| restoring && ( | |||
| <div className='flex flex-col mt-auto'> | |||
| <div className='flex items-center justify-end my-4'> | |||
| <Button className='text-components-button-secondary-text' onClick={handleShowFeatures}> | |||
| <RiApps2AddLine className='w-4 h-4 mr-1 text-components-button-secondary-text' /> | |||
| {t('workflow.common.features')} | |||
| </Button> | |||
| <div className='mx-2 w-[1px] h-3.5 bg-gray-200'></div> | |||
| <Button | |||
| className='mr-2' | |||
| onClick={handleCancelRestore} | |||
| > | |||
| {t('common.operation.cancel')} | |||
| </Button> | |||
| <Button | |||
| onClick={handleRestore} | |||
| variant='primary' | |||
| > | |||
| {t('workflow.common.restore')} | |||
| </Button> | |||
| </div> | |||
| <VersionHistoryModal /> | |||
| <div className='flex justify-end items-center gap-x-2'> | |||
| <Button | |||
| onClick={handleRestore} | |||
| disabled={!currentVersion || currentVersion.version === WorkflowVersion.Draft} | |||
| variant='primary' | |||
| > | |||
| {t('workflow.common.restore')} | |||
| </Button> | |||
| <Button | |||
| className='text-components-button-secondary-accent-text' | |||
| onClick={handleCancelRestore} | |||
| > | |||
| <div className='flex items-center gap-x-0.5'> | |||
| <RiHistoryLine className='w-4 h-4' /> | |||
| <span className='px-0.5'>{t('workflow.common.exitVersions')}</span> | |||
| </div> | |||
| </Button> | |||
| </div> | |||
| ) | |||
| } | |||
| @@ -1,19 +1,47 @@ | |||
| import { memo } from 'react' | |||
| import { memo, useMemo } 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' | |||
| import { WorkflowVersion } from '../types' | |||
| import useTimestamp from '@/hooks/use-timestamp' | |||
| const RestoringTitle = () => { | |||
| const { t } = useTranslation() | |||
| const { formatTimeFromNow } = useWorkflow() | |||
| const publishedAt = useStore(state => state.publishedAt) | |||
| 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 items-center h-[18px] text-xs text-gray-500'> | |||
| <ClockRefresh className='mr-1 w-3 h-3 text-gray-500' /> | |||
| {t('workflow.common.latestPublished')}<span> </span> | |||
| {formatTimeFromNow(publishedAt)} | |||
| <div className='flex flex-col gap-y-0.5'> | |||
| <div className='flex items-center gap-x-1'> | |||
| <span className='text-text-primary system-sm-semibold'> | |||
| {versionName} | |||
| </span> | |||
| <span className='px-1 py-0.5 rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm text-text-accent-secondary system-2xs-medium-uppercase'> | |||
| {t('workflow.common.viewOnly')} | |||
| </span> | |||
| </div> | |||
| <div className='flex items-center gap-x-1 h-4 text-text-tertiary system-xs-regular'> | |||
| { | |||
| 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> | |||
| ) | |||
| } | |||
| @@ -0,0 +1,66 @@ | |||
| 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 } from '../utils' | |||
| type VersionHistoryButtonProps = { | |||
| onClick: () => Promise<unknown> | unknown | |||
| } | |||
| const VERSION_HISTORY_SHORTCUT = ['⌘', '⇧', 'H'] | |||
| const PopupContent = React.memo(() => { | |||
| const { t } = useTranslation() | |||
| return ( | |||
| <div className='flex items-center gap-x-1'> | |||
| <div className='text-text-secondary system-xs-medium px-0.5'> | |||
| {t('workflow.common.versionHistory')} | |||
| </div> | |||
| <div className='flex items-center gap-x-0.5'> | |||
| {VERSION_HISTORY_SHORTCUT.map(key => ( | |||
| <span | |||
| key={key} | |||
| className='rounded-[4px] bg-components-kbd-bg-white text-text-tertiary system-kbd px-[1px]' | |||
| > | |||
| {key} | |||
| </span> | |||
| ))} | |||
| </div> | |||
| </div> | |||
| ) | |||
| }) | |||
| PopupContent.displayName = 'PopupContent' | |||
| const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({ | |||
| onClick, | |||
| }) => { | |||
| 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={'p-2'} | |||
| onClick={handleViewVersionHistory} | |||
| > | |||
| <RiHistoryLine className='w-4 h-4 text-components-button-secondary-text' /> | |||
| </Button> | |||
| </Tooltip> | |||
| } | |||
| export default VersionHistoryButton | |||
| @@ -1,66 +0,0 @@ | |||
| import React from 'react' | |||
| import dayjs from 'dayjs' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { WorkflowVersion } from '../types' | |||
| import cn from '@/utils/classnames' | |||
| import type { VersionHistory } from '@/types/workflow' | |||
| type VersionHistoryItemProps = { | |||
| item: VersionHistory | |||
| selectedVersion: string | |||
| onClick: (item: VersionHistory) => void | |||
| curIdx: number | |||
| page: number | |||
| } | |||
| const formatVersion = (version: string, curIdx: number, page: number): string => { | |||
| if (curIdx === 0 && page === 1) | |||
| return WorkflowVersion.Draft | |||
| if (curIdx === 1 && page === 1) | |||
| return WorkflowVersion.Latest | |||
| try { | |||
| const date = new Date(version) | |||
| if (isNaN(date.getTime())) | |||
| return version | |||
| // format as YYYY-MM-DD HH:mm:ss | |||
| return date.toISOString().slice(0, 19).replace('T', ' ') | |||
| } | |||
| catch { | |||
| return version | |||
| } | |||
| } | |||
| const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({ item, selectedVersion, onClick, curIdx, page }) => { | |||
| const { t } = useTranslation() | |||
| const formatTime = (time: number) => dayjs.unix(time).format('YYYY-MM-DD HH:mm:ss') | |||
| const formattedVersion = formatVersion(item.version, curIdx, page) | |||
| const renderVersionLabel = (version: string) => ( | |||
| (version === WorkflowVersion.Draft || version === WorkflowVersion.Latest) | |||
| ? ( | |||
| <div className="shrink-0 px-1 border bg-white border-[rgba(0,0,0,0.08)] rounded-[5px] truncate"> | |||
| {version} | |||
| </div> | |||
| ) | |||
| : null | |||
| ) | |||
| return ( | |||
| <div | |||
| className={cn( | |||
| 'flex items-center p-2 h-12 text-xs font-medium text-gray-700 justify-between', | |||
| formattedVersion === selectedVersion ? '' : 'hover:bg-gray-100', | |||
| formattedVersion === WorkflowVersion.Draft ? 'cursor-not-allowed' : 'cursor-pointer', | |||
| )} | |||
| onClick={() => item.version !== WorkflowVersion.Draft && onClick(item)} | |||
| > | |||
| <div className='flex flex-col gap-1 py-2'> | |||
| <span className="text-left">{formatTime(formattedVersion === WorkflowVersion.Draft ? item.updated_at : item.created_at)}</span> | |||
| <span className="text-left">{t('workflow.panel.createdBy')} {item.created_by.name}</span> | |||
| </div> | |||
| {renderVersionLabel(formattedVersion)} | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(VersionHistoryItem) | |||
| @@ -1,89 +0,0 @@ | |||
| 'use client' | |||
| import React, { useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import useSWR from 'swr' | |||
| import { useWorkflowRun } from '../hooks' | |||
| import VersionHistoryItem from './version-history-item' | |||
| import type { VersionHistory } from '@/types/workflow' | |||
| import { useStore as useAppStore } from '@/app/components/app/store' | |||
| import { fetchPublishedAllWorkflow } from '@/service/workflow' | |||
| import Loading from '@/app/components/base/loading' | |||
| import Button from '@/app/components/base/button' | |||
| const limit = 10 | |||
| const VersionHistoryModal = () => { | |||
| const [selectedVersion, setSelectedVersion] = useState('draft') | |||
| const [page, setPage] = useState(1) | |||
| const { handleRestoreFromPublishedWorkflow } = useWorkflowRun() | |||
| const appDetail = useAppStore.getState().appDetail | |||
| const { t } = useTranslation() | |||
| const { | |||
| data: versionHistory, | |||
| isLoading, | |||
| } = useSWR( | |||
| `/apps/${appDetail?.id}/workflows?page=${page}&limit=${limit}`, | |||
| fetchPublishedAllWorkflow, | |||
| ) | |||
| const handleVersionClick = (item: VersionHistory) => { | |||
| if (item.version !== selectedVersion) { | |||
| setSelectedVersion(item.version) | |||
| handleRestoreFromPublishedWorkflow(item) | |||
| } | |||
| } | |||
| const handleNextPage = () => { | |||
| if (versionHistory?.has_more) | |||
| setPage(page => page + 1) | |||
| } | |||
| return ( | |||
| <div className='w-[336px] bg-white rounded-2xl border-[0.5px] border-gray-200 shadow-xl p-2'> | |||
| <div className="max-h-[400px] overflow-auto"> | |||
| {(isLoading && page) === 1 | |||
| ? ( | |||
| <div className='flex items-center justify-center h-10'> | |||
| <Loading/> | |||
| </div> | |||
| ) | |||
| : ( | |||
| <> | |||
| {versionHistory?.items?.map((item, idx) => ( | |||
| <VersionHistoryItem | |||
| key={item.version} | |||
| item={item} | |||
| selectedVersion={selectedVersion} | |||
| onClick={handleVersionClick} | |||
| curIdx={idx} | |||
| page={page} | |||
| /> | |||
| ))} | |||
| {isLoading && page > 1 && ( | |||
| <div className='flex items-center justify-center h-10'> | |||
| <Loading/> | |||
| </div> | |||
| )} | |||
| {!isLoading && versionHistory?.has_more && ( | |||
| <div className='flex items-center justify-center h-10 mt-2'> | |||
| <Button | |||
| className='text-sm' | |||
| onClick={handleNextPage} | |||
| > | |||
| {t('workflow.common.loadMore')} | |||
| </Button> | |||
| </div> | |||
| )} | |||
| {!isLoading && !versionHistory?.items?.length && ( | |||
| <div className='flex items-center justify-center h-10 text-gray-500'> | |||
| {t('workflow.common.noHistory')} | |||
| </div> | |||
| )} | |||
| </> | |||
| )} | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(VersionHistoryModal) | |||
| @@ -105,7 +105,14 @@ export const useNodesSyncDraft = () => { | |||
| } | |||
| }, [getPostParams, params.appId, getNodesReadOnly]) | |||
| const doSyncWorkflowDraft = useCallback(async (notRefreshWhenSyncError?: boolean) => { | |||
| const doSyncWorkflowDraft = useCallback(async ( | |||
| notRefreshWhenSyncError?: boolean, | |||
| callback?: { | |||
| onSuccess?: () => void | |||
| onError?: () => void | |||
| onSettled?: () => void | |||
| }, | |||
| ) => { | |||
| if (getNodesReadOnly()) | |||
| return | |||
| const postParams = getPostParams() | |||
| @@ -119,6 +126,7 @@ export const useNodesSyncDraft = () => { | |||
| const res = await syncWorkflowDraft(postParams) | |||
| setSyncWorkflowDraftHash(res.hash) | |||
| setDraftUpdatedAt(res.updated_at) | |||
| callback?.onSuccess && callback.onSuccess() | |||
| } | |||
| catch (error: any) { | |||
| if (error && error.json && !error.bodyUsed) { | |||
| @@ -127,16 +135,28 @@ export const useNodesSyncDraft = () => { | |||
| handleRefreshWorkflowDraft() | |||
| }) | |||
| } | |||
| callback?.onError && callback.onError() | |||
| } | |||
| finally { | |||
| callback?.onSettled && callback.onSettled() | |||
| } | |||
| } | |||
| }, [workflowStore, getPostParams, getNodesReadOnly, handleRefreshWorkflowDraft]) | |||
| const handleSyncWorkflowDraft = useCallback((sync?: boolean, notRefreshWhenSyncError?: boolean) => { | |||
| const handleSyncWorkflowDraft = useCallback(( | |||
| sync?: boolean, | |||
| notRefreshWhenSyncError?: boolean, | |||
| callback?: { | |||
| onSuccess?: () => void | |||
| onError?: () => void | |||
| onSettled?: () => void | |||
| }, | |||
| ) => { | |||
| if (getNodesReadOnly()) | |||
| return | |||
| if (sync) | |||
| doSyncWorkflowDraft(notRefreshWhenSyncError) | |||
| doSyncWorkflowDraft(notRefreshWhenSyncError, callback) | |||
| else | |||
| debouncedSyncWorkflowDraft(doSyncWorkflowDraft) | |||
| }, [debouncedSyncWorkflowDraft, doSyncWorkflowDraft, getNodesReadOnly]) | |||
| @@ -13,7 +13,7 @@ const InfoPanel: FC<Props> = ({ | |||
| }) => { | |||
| return ( | |||
| <div> | |||
| <div className='px-[5px] py-[3px] bg-workflow-block-parma-bg rounded-md'> | |||
| <div className='flex flex-col gap-y-0.5 px-[5px] py-[3px] bg-workflow-block-parma-bg rounded-md'> | |||
| <div className='text-text-secondary system-2xs-semibold-uppercase uppercase'> | |||
| {title} | |||
| </div> | |||
| @@ -15,6 +15,7 @@ import ChatRecord from './chat-record' | |||
| import ChatVariablePanel from './chat-variable-panel' | |||
| import EnvPanel from './env-panel' | |||
| import GlobalVariablePanel from './global-variable-panel' | |||
| import VersionHistoryPanel from './version-history-panel' | |||
| import cn from '@/utils/classnames' | |||
| import { useStore as useAppStore } from '@/app/components/app/store' | |||
| import MessageLogModal from '@/app/components/base/message-log-modal' | |||
| @@ -28,6 +29,7 @@ const Panel: FC = () => { | |||
| const showEnvPanel = useStore(s => s.showEnvPanel) | |||
| const showChatVariablePanel = useStore(s => s.showChatVariablePanel) | |||
| const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel) | |||
| const showWorkflowVersionHistoryPanel = useStore(s => s.showWorkflowVersionHistoryPanel) | |||
| const isRestoring = useStore(s => s.isRestoring) | |||
| const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, currentLogModalActiveTab } = useAppStore(useShallow(state => ({ | |||
| currentLogItem: state.currentLogItem, | |||
| @@ -97,6 +99,11 @@ const Panel: FC = () => { | |||
| <GlobalVariablePanel /> | |||
| ) | |||
| } | |||
| { | |||
| showWorkflowVersionHistoryPanel && ( | |||
| <VersionHistoryPanel/> | |||
| ) | |||
| } | |||
| </div> | |||
| ) | |||
| } | |||
| @@ -0,0 +1,84 @@ | |||
| import React, { type FC, useCallback } from 'react' | |||
| import { RiMoreFill } from '@remixicon/react' | |||
| import { VersionHistoryContextMenuOptions } from '../../../types' | |||
| import MenuItem from './menu-item' | |||
| import useContextMenu from './use-context-menu' | |||
| import { | |||
| PortalToFollowElem, | |||
| PortalToFollowElemContent, | |||
| PortalToFollowElemTrigger, | |||
| } from '@/app/components/base/portal-to-follow-elem' | |||
| import Button from '@/app/components/base/button' | |||
| import Divider from '@/app/components/base/divider' | |||
| export type ContextMenuProps = { | |||
| isShowDelete: boolean | |||
| isNamedVersion: boolean | |||
| open: boolean | |||
| setOpen: React.Dispatch<React.SetStateAction<boolean>> | |||
| handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void | |||
| } | |||
| const ContextMenu: FC<ContextMenuProps> = (props: ContextMenuProps) => { | |||
| const { isShowDelete, handleClickMenuItem, open, setOpen } = props | |||
| const { | |||
| deleteOperation, | |||
| options, | |||
| } = useContextMenu(props) | |||
| const handleClickTrigger = useCallback((e: React.MouseEvent<HTMLButtonElement>) => { | |||
| e.stopPropagation() | |||
| setOpen(v => !v) | |||
| }, [setOpen]) | |||
| return ( | |||
| <PortalToFollowElem | |||
| placement={'bottom-end'} | |||
| offset={{ | |||
| mainAxis: 4, | |||
| crossAxis: 0, | |||
| }} | |||
| open={open} | |||
| onOpenChange={setOpen} | |||
| > | |||
| <PortalToFollowElemTrigger> | |||
| <Button size='small' className='px-1' onClick={handleClickTrigger}> | |||
| <RiMoreFill className='w-4 h-4' /> | |||
| </Button> | |||
| </PortalToFollowElemTrigger> | |||
| <PortalToFollowElemContent className='z-10'> | |||
| <div className='flex flex-col w-[184px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'> | |||
| <div className='flex flex-col p-1'> | |||
| { | |||
| options.map((option) => { | |||
| return ( | |||
| <MenuItem | |||
| key={option.key} | |||
| item={option} | |||
| onClick={handleClickMenuItem.bind(null, option.key)} | |||
| /> | |||
| ) | |||
| }) | |||
| } | |||
| </div> | |||
| { | |||
| isShowDelete && ( | |||
| <> | |||
| <Divider type='horizontal' className='h-[1px] bg-divider-subtle my-0' /> | |||
| <div className='p-1'> | |||
| <MenuItem | |||
| item={deleteOperation} | |||
| isDestructive | |||
| onClick={handleClickMenuItem.bind(null, VersionHistoryContextMenuOptions.delete)} | |||
| /> | |||
| </div> | |||
| </> | |||
| ) | |||
| } | |||
| </div> | |||
| </PortalToFollowElemContent> | |||
| </PortalToFollowElem> | |||
| ) | |||
| } | |||
| export default React.memo(ContextMenu) | |||
| @@ -0,0 +1,39 @@ | |||
| import React, { type FC } from 'react' | |||
| import type { VersionHistoryContextMenuOptions } from '../../../types' | |||
| import cn from '@/utils/classnames' | |||
| type MenuItemProps = { | |||
| item: { | |||
| key: VersionHistoryContextMenuOptions | |||
| name: string | |||
| } | |||
| onClick: (operation: VersionHistoryContextMenuOptions) => void | |||
| isDestructive?: boolean | |||
| } | |||
| const MenuItem: FC<MenuItemProps> = ({ | |||
| item, | |||
| onClick, | |||
| isDestructive = false, | |||
| }) => { | |||
| return ( | |||
| <div | |||
| className={cn( | |||
| 'flex items-center justify-between px-2 py-1.5 cursor-pointer rounded-lg ', | |||
| isDestructive ? 'hover:bg-state-destructive-hover' : 'hover:bg-state-base-hover', | |||
| )} | |||
| onClick={() => { | |||
| onClick(item.key) | |||
| }} | |||
| > | |||
| <div className={cn( | |||
| 'flex-1 text-text-primary system-md-regular', | |||
| isDestructive && 'hover:text-text-destructive', | |||
| )}> | |||
| {item.name} | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(MenuItem) | |||
| @@ -0,0 +1,42 @@ | |||
| import { useMemo } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { VersionHistoryContextMenuOptions } from '../../../types' | |||
| import type { ContextMenuProps } from './index' | |||
| const useContextMenu = (props: ContextMenuProps) => { | |||
| const { | |||
| isNamedVersion, | |||
| } = props | |||
| const { t } = useTranslation() | |||
| const deleteOperation = { | |||
| key: VersionHistoryContextMenuOptions.delete, | |||
| name: t('common.operation.delete'), | |||
| } | |||
| const options = useMemo(() => { | |||
| return [ | |||
| { | |||
| key: VersionHistoryContextMenuOptions.restore, | |||
| name: t('workflow.common.restore'), | |||
| }, | |||
| isNamedVersion | |||
| ? { | |||
| key: VersionHistoryContextMenuOptions.edit, | |||
| name: t('workflow.versionHistory.editVersionInfo'), | |||
| } | |||
| : { | |||
| key: VersionHistoryContextMenuOptions.edit, | |||
| name: t('workflow.versionHistory.nameThisVersion'), | |||
| }, | |||
| ] | |||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||
| }, [isNamedVersion]) | |||
| return { | |||
| deleteOperation, | |||
| options, | |||
| } | |||
| } | |||
| export default useContextMenu | |||
| @@ -0,0 +1,42 @@ | |||
| import React, { type FC } from 'react' | |||
| import Modal from '@/app/components/base/modal' | |||
| import type { VersionHistory } from '@/types/workflow' | |||
| import { useTranslation } from 'react-i18next' | |||
| import Button from '@/app/components/base/button' | |||
| type DeleteConfirmModalProps = { | |||
| isOpen: boolean | |||
| versionInfo: VersionHistory | |||
| onClose: () => void | |||
| onDelete: (id: string) => void | |||
| } | |||
| const DeleteConfirmModal: FC<DeleteConfirmModalProps> = ({ | |||
| isOpen, | |||
| versionInfo, | |||
| onClose, | |||
| onDelete, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| return <Modal className='p-0' isShow={isOpen} onClose={onClose}> | |||
| <div className='flex flex-col gap-y-2 p-6 pb-4 '> | |||
| <div className='text-text-primary title-2xl-semi-bold'> | |||
| {`${t('common.operation.delete')} ${versionInfo.marked_name || t('workflow.versionHistory.defaultName')}`} | |||
| </div> | |||
| <p className='text-text-secondary system-md-regular'> | |||
| {t('workflow.versionHistory.deletionTip')} | |||
| </p> | |||
| </div> | |||
| <div className='flex items-center justify-end gap-x-2 p-6'> | |||
| <Button onClick={onClose}> | |||
| {t('common.operation.cancel')} | |||
| </Button> | |||
| <Button variant='warning' onClick={onDelete.bind(null, versionInfo.id)}> | |||
| {t('common.operation.delete')} | |||
| </Button> | |||
| </div> | |||
| </Modal> | |||
| } | |||
| export default DeleteConfirmModal | |||
| @@ -0,0 +1,30 @@ | |||
| import Button from '@/app/components/base/button' | |||
| import { RiHistoryLine } from '@remixicon/react' | |||
| import React, { type FC } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| type EmptyProps = { | |||
| onResetFilter: () => void | |||
| } | |||
| const Empty: FC<EmptyProps> = ({ | |||
| onResetFilter, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| return <div className='h-5/6 w-full flex flex-col justify-center gap-y-2'> | |||
| <div className='flex justify-center'> | |||
| <RiHistoryLine className='w-10 h-10 text-text-empty-state-icon' /> | |||
| </div> | |||
| <div className='flex justify-center text-text-tertiary system-xs-regular'> | |||
| {t('workflow.versionHistory.filter.empty')} | |||
| </div> | |||
| <div className='flex justify-center'> | |||
| <Button size='small' onClick={onResetFilter}> | |||
| {t('workflow.versionHistory.filter.reset')} | |||
| </Button> | |||
| </div> | |||
| </div> | |||
| } | |||
| export default React.memo(Empty) | |||
| @@ -0,0 +1,32 @@ | |||
| import { RiCheckLine } from '@remixicon/react' | |||
| import React, { type FC } from 'react' | |||
| import type { WorkflowVersionFilterOptions } from '../../../types' | |||
| type FilterItemProps = { | |||
| item: { | |||
| key: WorkflowVersionFilterOptions | |||
| name: string | |||
| } | |||
| isSelected?: boolean | |||
| onClick: (value: WorkflowVersionFilterOptions) => void | |||
| } | |||
| const FilterItem: FC<FilterItemProps> = ({ | |||
| item, | |||
| isSelected = false, | |||
| onClick, | |||
| }) => { | |||
| return ( | |||
| <div | |||
| className='flex items-center justify-between gap-x-1 px-2 py-1.5 cursor-pointer rounded-lg hover:bg-state-base-hover' | |||
| onClick={() => { | |||
| onClick(item.key) | |||
| }} | |||
| > | |||
| <div className='flex-1 text-text-primary system-md-regular'>{item.name}</div> | |||
| {isSelected && <RiCheckLine className='w-4 h-4 text-text-accent shrink-0' />} | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(FilterItem) | |||
| @@ -0,0 +1,33 @@ | |||
| import React, { type FC } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import Switch from '@/app/components/base/switch' | |||
| type FilterSwitchProps = { | |||
| enabled: boolean | |||
| handleSwitch: (value: boolean) => void | |||
| } | |||
| const FilterSwitch: FC<FilterSwitchProps> = ({ | |||
| enabled, | |||
| handleSwitch, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| return ( | |||
| <div className='flex items-center p-1'> | |||
| <div className='flex items-center gap-x-1 w-full px-2 py-1.5'> | |||
| <div className='flex-1 px-1 text-text-secondary system-md-regular'> | |||
| {t('workflow.versionHistory.filter.onlyShowNamedVersions')} | |||
| </div> | |||
| <Switch | |||
| defaultValue={enabled} | |||
| onChange={v => handleSwitch(v)} | |||
| size='md' | |||
| className='shrink-0' | |||
| /> | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(FilterSwitch) | |||
| @@ -0,0 +1,81 @@ | |||
| import React, { type FC, useCallback, useState } from 'react' | |||
| import { RiFilter3Line } from '@remixicon/react' | |||
| import { WorkflowVersionFilterOptions } from '../../../types' | |||
| import { useFilterOptions } from './use-filter' | |||
| import FilterItem from './filter-item' | |||
| import FilterSwitch from './filter-switch' | |||
| import { | |||
| PortalToFollowElem, | |||
| PortalToFollowElemContent, | |||
| PortalToFollowElemTrigger, | |||
| } from '@/app/components/base/portal-to-follow-elem' | |||
| import Divider from '@/app/components/base/divider' | |||
| import cn from '@/utils/classnames' | |||
| type FilterProps = { | |||
| filterValue: WorkflowVersionFilterOptions | |||
| isOnlyShowNamedVersions: boolean | |||
| onClickFilterItem: (filter: WorkflowVersionFilterOptions) => void | |||
| handleSwitch: (isOnlyShowNamedVersions: boolean) => void | |||
| } | |||
| const Filter: FC<FilterProps> = ({ | |||
| filterValue, | |||
| isOnlyShowNamedVersions, | |||
| onClickFilterItem, | |||
| handleSwitch, | |||
| }) => { | |||
| const [open, setOpen] = useState(false) | |||
| const options = useFilterOptions() | |||
| const handleOnClick = useCallback((value: WorkflowVersionFilterOptions) => { | |||
| onClickFilterItem(value) | |||
| }, [onClickFilterItem]) | |||
| const isFiltering = filterValue !== WorkflowVersionFilterOptions.all || isOnlyShowNamedVersions | |||
| return ( | |||
| <PortalToFollowElem | |||
| placement={'bottom-end'} | |||
| offset={{ | |||
| mainAxis: 4, | |||
| crossAxis: 55, | |||
| }} | |||
| open={open} | |||
| onOpenChange={setOpen} | |||
| > | |||
| <PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}> | |||
| <div | |||
| className={cn( | |||
| 'flex items-center justify-center w-6 h-6 p-0.5 cursor-pointer rounded-md', | |||
| isFiltering ? 'bg-state-accent-active-alt' : 'hover:bg-state-base-hover', | |||
| )} | |||
| > | |||
| <RiFilter3Line className={cn('w-4 h-4', isFiltering ? 'text-text-accent' : ' text-text-tertiary')} /> | |||
| </div> | |||
| </PortalToFollowElemTrigger> | |||
| <PortalToFollowElemContent className='z-[12]'> | |||
| <div className='flex flex-col w-[248px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'> | |||
| <div className='flex flex-col p-1'> | |||
| { | |||
| options.map((option) => { | |||
| return ( | |||
| <FilterItem | |||
| key={option.key} | |||
| item={option} | |||
| isSelected={filterValue === option.key} | |||
| onClick={handleOnClick} | |||
| /> | |||
| ) | |||
| }) | |||
| } | |||
| </div> | |||
| <Divider type='horizontal' className='h-[1px] bg-divider-subtle my-0' /> | |||
| <FilterSwitch enabled={isOnlyShowNamedVersions} handleSwitch={handleSwitch} /> | |||
| </div> | |||
| </PortalToFollowElemContent> | |||
| </PortalToFollowElem> | |||
| ) | |||
| } | |||
| export default Filter | |||
| @@ -0,0 +1,17 @@ | |||
| import { useTranslation } from 'react-i18next' | |||
| import { WorkflowVersionFilterOptions } from '../../../types' | |||
| export const useFilterOptions = () => { | |||
| const { t } = useTranslation() | |||
| return [ | |||
| { | |||
| key: WorkflowVersionFilterOptions.all, | |||
| name: t('workflow.versionHistory.filter.all'), | |||
| }, | |||
| { | |||
| key: WorkflowVersionFilterOptions.onlyYours, | |||
| name: t('workflow.versionHistory.filter.onlyYours'), | |||
| }, | |||
| ] | |||
| } | |||
| @@ -0,0 +1,278 @@ | |||
| 'use client' | |||
| import React, { useCallback, useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { RiArrowDownDoubleLine, RiCloseLine, RiLoader2Line } from '@remixicon/react' | |||
| import { useNodesSyncDraft, useWorkflowRun } from '../../hooks' | |||
| import { useStore, useWorkflowStore } from '../../store' | |||
| import { VersionHistoryContextMenuOptions, WorkflowVersionFilterOptions } from '../../types' | |||
| import VersionHistoryItem from './version-history-item' | |||
| import Filter from './filter' | |||
| import type { VersionHistory } from '@/types/workflow' | |||
| import { useStore as useAppStore } from '@/app/components/app/store' | |||
| import { useDeleteWorkflow, useResetWorkflowVersionHistory, useUpdateWorkflow, useWorkflowVersionHistory } from '@/service/use-workflow' | |||
| import Divider from '@/app/components/base/divider' | |||
| import Loading from './loading' | |||
| import Empty from './empty' | |||
| import { useSelector as useAppContextSelector } from '@/context/app-context' | |||
| import RestoreConfirmModal from './restore-confirm-modal' | |||
| import DeleteConfirmModal from './delete-confirm-modal' | |||
| import VersionInfoModal from '@/app/components/app/app-publisher/version-info-modal' | |||
| import Toast from '@/app/components/base/toast' | |||
| const HISTORY_PER_PAGE = 10 | |||
| const INITIAL_PAGE = 1 | |||
| const VersionHistoryPanel = () => { | |||
| const [filterValue, setFilterValue] = useState(WorkflowVersionFilterOptions.all) | |||
| const [isOnlyShowNamedVersions, setIsOnlyShowNamedVersions] = useState(false) | |||
| const [operatedItem, setOperatedItem] = useState<VersionHistory>() | |||
| const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false) | |||
| const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false) | |||
| const [editModalOpen, setEditModalOpen] = useState(false) | |||
| const workflowStore = useWorkflowStore() | |||
| const { handleSyncWorkflowDraft } = useNodesSyncDraft() | |||
| const { handleRestoreFromPublishedWorkflow, handleLoadBackupDraft } = useWorkflowRun() | |||
| const appDetail = useAppStore.getState().appDetail | |||
| const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel) | |||
| const currentVersion = useStore(s => s.currentVersion) | |||
| const setCurrentVersion = useStore(s => s.setCurrentVersion) | |||
| const userProfile = useAppContextSelector(s => s.userProfile) | |||
| const { t } = useTranslation() | |||
| const { | |||
| data: versionHistory, | |||
| fetchNextPage, | |||
| hasNextPage, | |||
| isFetching, | |||
| } = useWorkflowVersionHistory({ | |||
| appId: appDetail!.id, | |||
| initialPage: INITIAL_PAGE, | |||
| limit: HISTORY_PER_PAGE, | |||
| userId: filterValue === WorkflowVersionFilterOptions.onlyYours ? userProfile.id : '', | |||
| namedOnly: isOnlyShowNamedVersions, | |||
| }) | |||
| const handleVersionClick = useCallback((item: VersionHistory) => { | |||
| if (item.id !== currentVersion?.id) { | |||
| setCurrentVersion(item) | |||
| handleRestoreFromPublishedWorkflow(item) | |||
| } | |||
| }, [currentVersion?.id, setCurrentVersion, handleRestoreFromPublishedWorkflow]) | |||
| const handleNextPage = () => { | |||
| if (hasNextPage) | |||
| fetchNextPage() | |||
| } | |||
| const handleClose = () => { | |||
| handleLoadBackupDraft() | |||
| workflowStore.setState({ isRestoring: false }) | |||
| setShowWorkflowVersionHistoryPanel(false) | |||
| } | |||
| const handleClickFilterItem = useCallback((value: WorkflowVersionFilterOptions) => { | |||
| setFilterValue(value) | |||
| }, []) | |||
| const handleSwitch = useCallback((value: boolean) => { | |||
| setIsOnlyShowNamedVersions(value) | |||
| }, []) | |||
| const handleResetFilter = useCallback(() => { | |||
| setFilterValue(WorkflowVersionFilterOptions.all) | |||
| setIsOnlyShowNamedVersions(false) | |||
| }, []) | |||
| const handleClickMenuItem = useCallback((item: VersionHistory, operation: VersionHistoryContextMenuOptions) => { | |||
| setOperatedItem(item) | |||
| switch (operation) { | |||
| case VersionHistoryContextMenuOptions.restore: | |||
| setRestoreConfirmOpen(true) | |||
| break | |||
| case VersionHistoryContextMenuOptions.edit: | |||
| setEditModalOpen(true) | |||
| break | |||
| case VersionHistoryContextMenuOptions.delete: | |||
| setDeleteConfirmOpen(true) | |||
| break | |||
| } | |||
| }, []) | |||
| const handleCancel = useCallback((operation: VersionHistoryContextMenuOptions) => { | |||
| switch (operation) { | |||
| case VersionHistoryContextMenuOptions.restore: | |||
| setRestoreConfirmOpen(false) | |||
| break | |||
| case VersionHistoryContextMenuOptions.edit: | |||
| setEditModalOpen(false) | |||
| break | |||
| case VersionHistoryContextMenuOptions.delete: | |||
| setDeleteConfirmOpen(false) | |||
| break | |||
| } | |||
| }, []) | |||
| const resetWorkflowVersionHistory = useResetWorkflowVersionHistory(appDetail!.id) | |||
| const handleRestore = useCallback((item: VersionHistory) => { | |||
| setShowWorkflowVersionHistoryPanel(false) | |||
| handleRestoreFromPublishedWorkflow(item) | |||
| 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: () => { | |||
| resetWorkflowVersionHistory() | |||
| }, | |||
| }) | |||
| }, [setShowWorkflowVersionHistoryPanel, handleSyncWorkflowDraft, workflowStore, handleRestoreFromPublishedWorkflow, resetWorkflowVersionHistory, t]) | |||
| const { mutateAsync: deleteWorkflow } = useDeleteWorkflow(appDetail!.id) | |||
| const handleDelete = useCallback(async (id: string) => { | |||
| await deleteWorkflow(id, { | |||
| onSuccess: () => { | |||
| setDeleteConfirmOpen(false) | |||
| Toast.notify({ | |||
| type: 'success', | |||
| message: t('workflow.versionHistory.action.deleteSuccess'), | |||
| }) | |||
| resetWorkflowVersionHistory() | |||
| }, | |||
| onError: () => { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('workflow.versionHistory.action.deleteFailure'), | |||
| }) | |||
| }, | |||
| onSettled: () => { | |||
| setDeleteConfirmOpen(false) | |||
| }, | |||
| }) | |||
| }, [t, deleteWorkflow, resetWorkflowVersionHistory]) | |||
| const { mutateAsync: updateWorkflow } = useUpdateWorkflow(appDetail!.id) | |||
| const handleUpdateWorkflow = useCallback(async (params: { id?: string, title: string, releaseNotes: string }) => { | |||
| const { id, ...rest } = params | |||
| await updateWorkflow({ | |||
| workflowId: id!, | |||
| ...rest, | |||
| }, { | |||
| onSuccess: () => { | |||
| setEditModalOpen(false) | |||
| Toast.notify({ | |||
| type: 'success', | |||
| message: t('workflow.versionHistory.action.updateSuccess'), | |||
| }) | |||
| resetWorkflowVersionHistory() | |||
| }, | |||
| onError: () => { | |||
| Toast.notify({ | |||
| type: 'error', | |||
| message: t('workflow.versionHistory.action.updateFailure'), | |||
| }) | |||
| }, | |||
| onSettled: () => { | |||
| setEditModalOpen(false) | |||
| }, | |||
| }) | |||
| }, [t, updateWorkflow, resetWorkflowVersionHistory]) | |||
| return ( | |||
| <div className='flex flex-col w-[268px] bg-components-panel-bg rounded-l-2xl border-y-[0.5px] border-l-[0.5px] border-components-panel-border shadow-xl shadow-shadow-shadow-5'> | |||
| <div className='flex items-center gap-x-2 px-4 pt-3'> | |||
| <div className='flex-1 py-1 text-text-primary system-xl-semibold'>{t('workflow.versionHistory.title')}</div> | |||
| <Filter | |||
| filterValue={filterValue} | |||
| isOnlyShowNamedVersions={isOnlyShowNamedVersions} | |||
| onClickFilterItem={handleClickFilterItem} | |||
| handleSwitch={handleSwitch} | |||
| /> | |||
| <Divider type='vertical' className='h-3.5 mx-1' /> | |||
| <div | |||
| className='flex items-center justify-center w-6 h-6 p-0.5 cursor-pointer' | |||
| onClick={handleClose} | |||
| > | |||
| <RiCloseLine className='w-4 h-4 text-text-tertiary' /> | |||
| </div> | |||
| </div> | |||
| <div className="flex-1 relative px-3 py-2 overflow-y-auto"> | |||
| {(isFetching && !versionHistory?.pages?.length) | |||
| ? ( | |||
| <Loading /> | |||
| ) | |||
| : ( | |||
| <> | |||
| {versionHistory?.pages?.map((page, pageNumber) => ( | |||
| page.items?.map((item, idx) => { | |||
| const isLast = pageNumber === versionHistory.pages.length - 1 && idx === page.items.length - 1 | |||
| return <VersionHistoryItem | |||
| key={item.id} | |||
| item={item} | |||
| currentVersion={currentVersion} | |||
| latestVersionId={appDetail!.workflow!.id} | |||
| onClick={handleVersionClick} | |||
| handleClickMenuItem={handleClickMenuItem.bind(null, item)} | |||
| isLast={isLast} | |||
| /> | |||
| }) | |||
| ))} | |||
| {hasNextPage && ( | |||
| <div className='flex absolute bottom-2 left-2 p-2'> | |||
| <div | |||
| className='flex items-center gap-x-1 cursor-pointer' | |||
| onClick={handleNextPage} | |||
| > | |||
| <div className='flex item-center justify-center p-0.5'> | |||
| { | |||
| isFetching | |||
| ? <RiLoader2Line className='w-3.5 h-3.5 text-text-accent animate-spin' /> | |||
| : <RiArrowDownDoubleLine className='w-3.5 h-3.5 text-text-accent' />} | |||
| </div> | |||
| <div className='py-[1px] text-text-accent system-xs-medium-uppercase'> | |||
| {t('workflow.common.loadMore')} | |||
| </div> | |||
| </div> | |||
| </div> | |||
| )} | |||
| {!isFetching && (!versionHistory?.pages?.length || !versionHistory.pages[0].items.length) && ( | |||
| <Empty onResetFilter={handleResetFilter} /> | |||
| )} | |||
| </> | |||
| )} | |||
| </div> | |||
| {restoreConfirmOpen && (<RestoreConfirmModal | |||
| isOpen={restoreConfirmOpen} | |||
| versionInfo={operatedItem!} | |||
| onClose={handleCancel.bind(null, VersionHistoryContextMenuOptions.restore)} | |||
| onRestore={handleRestore} | |||
| />)} | |||
| {deleteConfirmOpen && (<DeleteConfirmModal | |||
| isOpen={deleteConfirmOpen} | |||
| versionInfo={operatedItem!} | |||
| onClose={handleCancel.bind(null, VersionHistoryContextMenuOptions.delete)} | |||
| onDelete={handleDelete} | |||
| />)} | |||
| {editModalOpen && (<VersionInfoModal | |||
| isOpen={editModalOpen} | |||
| versionInfo={operatedItem} | |||
| onClose={handleCancel.bind(null, VersionHistoryContextMenuOptions.edit)} | |||
| onPublish={handleUpdateWorkflow} | |||
| />)} | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(VersionHistoryPanel) | |||
| @@ -0,0 +1,19 @@ | |||
| import Item from './item' | |||
| const itemConfig = Array.from({ length: 8 }).map((_, index) => { | |||
| return { | |||
| isFirst: index === 0, | |||
| isLast: index === 7, | |||
| titleWidth: (index + 1) % 2 === 0 ? 'w-1/3' : 'w-2/5', | |||
| releaseNotesWidth: (index + 1) % 2 === 0 ? 'w-3/4' : 'w-4/6', | |||
| } | |||
| }) | |||
| const Loading = () => { | |||
| return <div className='relative w-full overflow-y-hidden'> | |||
| <div className='absolute z-10 top-0 left-0 w-full h-full bg-dataset-chunk-list-mask-bg' /> | |||
| {itemConfig.map((config, index) => <Item key={index} {...config} />)} | |||
| </div> | |||
| } | |||
| export default Loading | |||
| @@ -0,0 +1,40 @@ | |||
| import React, { type FC } from 'react' | |||
| import cn from '@/utils/classnames' | |||
| type ItemProps = { | |||
| titleWidth: string | |||
| releaseNotesWidth: string | |||
| isFirst: boolean | |||
| isLast: boolean | |||
| } | |||
| const Item: FC<ItemProps> = ({ | |||
| titleWidth, | |||
| releaseNotesWidth, | |||
| isFirst, | |||
| isLast, | |||
| }) => { | |||
| return ( | |||
| <div className='flex gap-x-1 relative p-2' > | |||
| {!isLast && <div className='absolute w-0.5 h-[calc(100%-0.75rem)] left-4 top-6 bg-divider-subtle' />} | |||
| <div className=' flex items-center justify-center shrink-0 w-[18px] h-5'> | |||
| <div className='w-2 h-2 border-[2px] rounded-lg border-text-quaternary' /> | |||
| </div> | |||
| <div className='flex flex-col grow gap-y-0.5'> | |||
| <div className='flex items-center h-3.5'> | |||
| <div className={cn('h-2 w-full bg-text-quaternary rounded-sm opacity-20', titleWidth)} /> | |||
| </div> | |||
| { | |||
| !isFirst && ( | |||
| <div className='flex items-center h-3'> | |||
| <div className={cn('h-1.5 w-full bg-text-quaternary rounded-sm opacity-20', releaseNotesWidth)} /> | |||
| </div> | |||
| ) | |||
| } | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(Item) | |||
| @@ -0,0 +1,42 @@ | |||
| import React, { type FC } from 'react' | |||
| import Modal from '@/app/components/base/modal' | |||
| import type { VersionHistory } from '@/types/workflow' | |||
| import { useTranslation } from 'react-i18next' | |||
| import Button from '@/app/components/base/button' | |||
| type RestoreConfirmModalProps = { | |||
| isOpen: boolean | |||
| versionInfo: VersionHistory | |||
| onClose: () => void | |||
| onRestore: (item: VersionHistory) => void | |||
| } | |||
| const RestoreConfirmModal: FC<RestoreConfirmModalProps> = ({ | |||
| isOpen, | |||
| versionInfo, | |||
| onClose, | |||
| onRestore, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| return <Modal className='p-0' isShow={isOpen} onClose={onClose}> | |||
| <div className='flex flex-col gap-y-2 p-6 pb-4 '> | |||
| <div className='text-text-primary title-2xl-semi-bold'> | |||
| {`${t('workflow.common.restore')} ${versionInfo.marked_name || t('workflow.versionHistory.defaultName')}`} | |||
| </div> | |||
| <p className='text-text-secondary system-md-regular'> | |||
| {t('workflow.versionHistory.restorationTip')} | |||
| </p> | |||
| </div> | |||
| <div className='flex items-center justify-end gap-x-2 p-6'> | |||
| <Button onClick={onClose}> | |||
| {t('common.operation.cancel')} | |||
| </Button> | |||
| <Button variant='primary' onClick={onRestore.bind(null, versionInfo)}> | |||
| {t('workflow.common.restore')} | |||
| </Button> | |||
| </div> | |||
| </Modal> | |||
| } | |||
| export default RestoreConfirmModal | |||
| @@ -0,0 +1,137 @@ | |||
| import React, { useEffect, useState } from 'react' | |||
| import dayjs from 'dayjs' | |||
| import { useTranslation } from 'react-i18next' | |||
| import ContextMenu from './context-menu' | |||
| import cn from '@/utils/classnames' | |||
| import type { VersionHistory } from '@/types/workflow' | |||
| import { type VersionHistoryContextMenuOptions, WorkflowVersion } from '../../types' | |||
| type VersionHistoryItemProps = { | |||
| item: VersionHistory | |||
| currentVersion: VersionHistory | null | |||
| latestVersionId: string | |||
| onClick: (item: VersionHistory) => void | |||
| handleClickMenuItem: (operation: VersionHistoryContextMenuOptions) => void | |||
| isLast: boolean | |||
| } | |||
| const formatVersion = (versionHistory: VersionHistory, latestVersionId: string): string => { | |||
| const { version, id } = versionHistory | |||
| if (version === WorkflowVersion.Draft) | |||
| return WorkflowVersion.Draft | |||
| if (id === latestVersionId) | |||
| return WorkflowVersion.Latest | |||
| try { | |||
| const date = new Date(version) | |||
| if (Number.isNaN(date.getTime())) | |||
| return version | |||
| // format as YYYY-MM-DD HH:mm:ss | |||
| return date.toISOString().slice(0, 19).replace('T', ' ') | |||
| } | |||
| catch { | |||
| return version | |||
| } | |||
| } | |||
| const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({ | |||
| item, | |||
| currentVersion, | |||
| latestVersionId, | |||
| onClick, | |||
| handleClickMenuItem, | |||
| isLast, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| const [isHovering, setIsHovering] = useState(false) | |||
| const [open, setOpen] = useState(false) | |||
| const formatTime = (time: number) => dayjs.unix(time).format('YYYY-MM-DD HH:mm') | |||
| const formattedVersion = formatVersion(item, latestVersionId) | |||
| const isSelected = item.version === currentVersion?.version | |||
| const isDraft = formattedVersion === WorkflowVersion.Draft | |||
| const isLatest = formattedVersion === WorkflowVersion.Latest | |||
| useEffect(() => { | |||
| if (isDraft) | |||
| onClick(item) | |||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||
| }, []) | |||
| const handleClickItem = () => { | |||
| if (isSelected) | |||
| return | |||
| onClick(item) | |||
| } | |||
| return ( | |||
| <div | |||
| className={cn( | |||
| 'flex gap-x-1 relative p-2 rounded-lg group', | |||
| isSelected ? 'bg-state-accent-active cursor-not-allowed' : 'hover:bg-state-base-hover cursor-pointer', | |||
| )} | |||
| onClick={handleClickItem} | |||
| onMouseEnter={() => setIsHovering(true)} | |||
| onMouseLeave={() => { | |||
| setIsHovering(false) | |||
| setOpen(false) | |||
| }} | |||
| onContextMenu={(e) => { | |||
| e.preventDefault() | |||
| setOpen(true) | |||
| }} | |||
| > | |||
| {!isLast && <div className='absolute w-0.5 h-[calc(100%-0.75rem)] left-4 top-6 bg-divider-subtle' />} | |||
| <div className=' flex items-center justify-center shrink-0 w-[18px] h-5'> | |||
| <div className={cn( | |||
| 'w-2 h-2 border-[2px] rounded-lg', | |||
| isSelected ? 'border-text-accent' : 'border-text-quaternary', | |||
| )}/> | |||
| </div> | |||
| <div className='flex flex-col gap-y-0.5 grow overflow-hidden'> | |||
| <div className='flex items-center gap-x-1 h-5 mr-6'> | |||
| <div className={cn( | |||
| 'py-[1px] system-sm-semibold truncate', | |||
| isSelected ? 'text-text-accent' : 'text-text-secondary', | |||
| )}> | |||
| {isDraft ? t('workflow.versionHistory.currentDraft') : item.marked_name || t('workflow.versionHistory.defaultName')} | |||
| </div> | |||
| {isLatest && ( | |||
| <div className='flex items-center shrink-0 h-5 px-[5px] rounded-md border border-text-accent-secondary | |||
| bg-components-badge-bg-dimm text-text-accent-secondary system-2xs-medium-uppercase'> | |||
| {t('workflow.versionHistory.latest')} | |||
| </div> | |||
| )} | |||
| </div> | |||
| { | |||
| !isDraft && ( | |||
| <div className='text-text-secondary system-xs-regular break-words'> | |||
| {item.marked_comment || ''} | |||
| </div> | |||
| ) | |||
| } | |||
| { | |||
| !isDraft && ( | |||
| <div className='text-text-tertiary system-xs-regular truncate'> | |||
| {`${formatTime(item.created_at)} · ${item.created_by.name}`} | |||
| </div> | |||
| ) | |||
| } | |||
| </div> | |||
| {/* Context Menu */} | |||
| {!isDraft && isHovering && ( | |||
| <div className='absolute right-1 top-1'> | |||
| <ContextMenu | |||
| isShowDelete={!isLatest} | |||
| isNamedVersion={!!item.marked_name} | |||
| open={open} | |||
| setOpen={setOpen} | |||
| handleClickMenuItem={handleClickMenuItem} | |||
| /> | |||
| </div> | |||
| )} | |||
| </div> | |||
| ) | |||
| } | |||
| export default React.memo(VersionHistoryItem) | |||
| @@ -67,6 +67,10 @@ type Shape = { | |||
| setDraftUpdatedAt: (draftUpdatedAt: number) => void | |||
| publishedAt: number | |||
| setPublishedAt: (publishedAt: number) => void | |||
| currentVersion: VersionHistory | null | |||
| setCurrentVersion: (currentVersion: VersionHistory) => void | |||
| showWorkflowVersionHistoryPanel: boolean | |||
| setShowWorkflowVersionHistoryPanel: (showWorkflowVersionHistoryPanel: boolean) => void | |||
| showInputsPanel: boolean | |||
| setShowInputsPanel: (showInputsPanel: boolean) => void | |||
| inputs: Record<string, string> | |||
| @@ -205,6 +209,10 @@ export const createWorkflowStore = () => { | |||
| setDraftUpdatedAt: draftUpdatedAt => set(() => ({ draftUpdatedAt: draftUpdatedAt ? draftUpdatedAt * 1000 : 0 })), | |||
| publishedAt: 0, | |||
| setPublishedAt: publishedAt => set(() => ({ publishedAt: publishedAt ? publishedAt * 1000 : 0 })), | |||
| currentVersion: null, | |||
| setCurrentVersion: currentVersion => set(() => ({ currentVersion })), | |||
| showWorkflowVersionHistoryPanel: false, | |||
| setShowWorkflowVersionHistoryPanel: showWorkflowVersionHistoryPanel => set(() => ({ showWorkflowVersionHistoryPanel })), | |||
| showInputsPanel: false, | |||
| setShowInputsPanel: showInputsPanel => set(() => ({ showInputsPanel })), | |||
| inputs: {}, | |||
| @@ -411,3 +411,14 @@ export type VisionSetting = { | |||
| variable_selector: ValueSelector | |||
| detail: Resolution | |||
| } | |||
| export enum WorkflowVersionFilterOptions { | |||
| all = 'all', | |||
| onlyYours = 'onlyYours', | |||
| } | |||
| export enum VersionHistoryContextMenuOptions { | |||
| restore = 'restore', | |||
| edit = 'edit', | |||
| delete = 'delete', | |||
| } | |||
| @@ -101,7 +101,7 @@ const translation = { | |||
| plans: { | |||
| sandbox: { | |||
| name: 'Sandbox', | |||
| for: 'Free Trial of Core Capabilities', | |||
| for: 'Free Trial of Core Capabilities', | |||
| description: 'Free Trial of Core Capabilities', | |||
| }, | |||
| professional: { | |||
| @@ -8,6 +8,7 @@ const translation = { | |||
| published: 'Published', | |||
| publish: 'Publish', | |||
| update: 'Update', | |||
| publishUpdate: 'Publish Update', | |||
| run: 'Run', | |||
| running: 'Running', | |||
| inRunMode: 'In Run Mode', | |||
| @@ -30,6 +31,8 @@ const translation = { | |||
| latestPublished: 'Latest Published', | |||
| publishedAt: 'Published', | |||
| restore: 'Restore', | |||
| versionHistory: 'Version History', | |||
| exitVersions: 'Exit Versions', | |||
| runApp: 'Run App', | |||
| batchRunApp: 'Batch Run App', | |||
| openInExplore: 'Open in Explore', | |||
| @@ -104,7 +107,7 @@ const translation = { | |||
| branch: 'BRANCH', | |||
| onFailure: 'On Failure', | |||
| addFailureBranch: 'Add Fail Branch', | |||
| loadMore: 'Load More Workflows', | |||
| loadMore: 'Load More', | |||
| noHistory: 'No History', | |||
| }, | |||
| env: { | |||
| @@ -797,6 +800,38 @@ const translation = { | |||
| tracing: { | |||
| stopBy: 'Stop by {{user}}', | |||
| }, | |||
| versionHistory: { | |||
| title: 'Versions', | |||
| currentDraft: 'Current Draft', | |||
| latest: 'Latest', | |||
| filter: { | |||
| all: 'All', | |||
| onlyYours: 'Only yours', | |||
| onlyShowNamedVersions: 'Only show named versions', | |||
| reset: 'Reset Filter', | |||
| empty: 'No matching version history found', | |||
| }, | |||
| defaultName: 'Untitled Version', | |||
| nameThisVersion: 'Name this version', | |||
| editVersionInfo: 'Edit version info', | |||
| editField: { | |||
| title: 'Title', | |||
| releaseNotes: 'Release Notes', | |||
| titleLengthLimit: 'Title can\'t exceed {{limit}} characters', | |||
| releaseNotesLengthLimit: 'Release notes can\'t exceed {{limit}} characters', | |||
| }, | |||
| releaseNotesPlaceholder: 'Describe what changed', | |||
| restorationTip: 'After version restoration, the current draft will be overwritten.', | |||
| deletionTip: 'Deletion is irreversible, please confirm.', | |||
| action: { | |||
| restoreSuccess: 'Version restored', | |||
| restoreFailure: 'Failed to restore version', | |||
| deleteSuccess: 'Version deleted', | |||
| deleteFailure: 'Failed to delete version', | |||
| updateSuccess: 'Version updated', | |||
| updateFailure: 'Failed to update version', | |||
| }, | |||
| }, | |||
| } | |||
| export default translation | |||
| @@ -100,7 +100,7 @@ const translation = { | |||
| plans: { | |||
| sandbox: { | |||
| name: 'Sandbox', | |||
| for: '核心能力的免费试用', | |||
| for: '核心能力的免费试用', | |||
| description: '核心功能免费试用', | |||
| }, | |||
| professional: { | |||
| @@ -8,6 +8,7 @@ const translation = { | |||
| published: '已发布', | |||
| publish: '发布', | |||
| update: '更新', | |||
| publishUpdate: '发布更新', | |||
| run: '运行', | |||
| running: '运行中', | |||
| inRunMode: '在运行模式中', | |||
| @@ -30,6 +31,8 @@ const translation = { | |||
| latestPublished: '最新发布', | |||
| publishedAt: '发布于', | |||
| restore: '恢复', | |||
| versionHistory: '版本历史', | |||
| exitVersions: '退出版本历史', | |||
| runApp: '运行', | |||
| batchRunApp: '批量运行', | |||
| accessAPIReference: '访问 API', | |||
| @@ -798,6 +801,38 @@ const translation = { | |||
| tracing: { | |||
| stopBy: '由{{user}}终止', | |||
| }, | |||
| versionHistory: { | |||
| title: '版本', | |||
| currentDraft: '当前草稿', | |||
| latest: '最新', | |||
| filter: { | |||
| all: '全部', | |||
| onlyYours: '仅你的', | |||
| onlyShowNamedVersions: '只显示已命名版本', | |||
| reset: '重置', | |||
| empty: '没有匹配的版本', | |||
| }, | |||
| defaultName: '未命名', | |||
| nameThisVersion: '命名', | |||
| editVersionInfo: '编辑信息', | |||
| editField: { | |||
| title: '标题', | |||
| releaseNotes: '发布说明', | |||
| titleLengthLimit: '标题不能超过{{limit}}个字符', | |||
| releaseNotesLengthLimit: '发布说明不能超过{{limit}}个字符', | |||
| }, | |||
| releaseNotesPlaceholder: '请描述变更', | |||
| restorationTip: '版本回滚后,当前草稿将被覆盖。', | |||
| deletionTip: '删除不可逆,请确认。', | |||
| action: { | |||
| restoreSuccess: '回滚成功', | |||
| restoreFailure: '回滚失败', | |||
| deleteSuccess: '版本已删除', | |||
| deleteFailure: '删除失败', | |||
| updateSuccess: '版本信息已更新', | |||
| updateFailure: '更新失败', | |||
| }, | |||
| }, | |||
| } | |||
| export default translation | |||
| @@ -13,3 +13,14 @@ export const useInvalid = (key: QueryKey) => { | |||
| ) | |||
| } | |||
| } | |||
| export const useReset = (key: QueryKey) => { | |||
| const queryClient = useQueryClient() | |||
| return () => { | |||
| queryClient.resetQueries( | |||
| { | |||
| queryKey: key, | |||
| }, | |||
| ) | |||
| } | |||
| } | |||
| @@ -1,9 +1,15 @@ | |||
| import { get } from './base' | |||
| import { del, get, patch, post } from './base' | |||
| import { useInfiniteQuery, useMutation, useQuery } from '@tanstack/react-query' | |||
| import type { | |||
| FetchWorkflowDraftPageParams, | |||
| FetchWorkflowDraftPageResponse, | |||
| FetchWorkflowDraftResponse, | |||
| PublishWorkflowParams, | |||
| UpdateWorkflowParams, | |||
| WorkflowConfigResponse, | |||
| } from '@/types/workflow' | |||
| import { useQuery } from '@tanstack/react-query' | |||
| import type { WorkflowConfigResponse } from '@/types/workflow' | |||
| import type { CommonResponse } from '@/models/common' | |||
| import { useReset } from './use-base' | |||
| const NAME_SPACE = 'workflow' | |||
| @@ -21,3 +27,57 @@ export const useWorkflowConfig = (appId: string) => { | |||
| queryFn: () => get<WorkflowConfigResponse>(`/apps/${appId}/workflows/draft/config`), | |||
| }) | |||
| } | |||
| const WorkflowVersionHistoryKey = [NAME_SPACE, 'versionHistory'] | |||
| export const useWorkflowVersionHistory = (params: FetchWorkflowDraftPageParams) => { | |||
| const { appId, initialPage, limit, userId, namedOnly } = params | |||
| return useInfiniteQuery({ | |||
| queryKey: [...WorkflowVersionHistoryKey, appId, initialPage, limit, userId, namedOnly], | |||
| queryFn: ({ pageParam = 1 }) => get<FetchWorkflowDraftPageResponse>(`/apps/${appId}/workflows`, { | |||
| params: { | |||
| page: pageParam, | |||
| limit, | |||
| user_id: userId || '', | |||
| named_only: !!namedOnly, | |||
| }, | |||
| }), | |||
| getNextPageParam: lastPage => lastPage.has_more ? lastPage.page + 1 : null, | |||
| initialPageParam: initialPage, | |||
| }) | |||
| } | |||
| export const useResetWorkflowVersionHistory = (appId: string) => { | |||
| return useReset([...WorkflowVersionHistoryKey, appId]) | |||
| } | |||
| export const useUpdateWorkflow = (appId: string) => { | |||
| return useMutation({ | |||
| mutationKey: [NAME_SPACE, 'update'], | |||
| mutationFn: (params: UpdateWorkflowParams) => patch(`/apps/${appId}/workflows/${params.workflowId}`, { | |||
| body: { | |||
| marked_name: params.title, | |||
| marked_comment: params.releaseNotes, | |||
| }, | |||
| }), | |||
| }) | |||
| } | |||
| export const useDeleteWorkflow = (appId: string) => { | |||
| return useMutation({ | |||
| mutationKey: [NAME_SPACE, 'delete'], | |||
| mutationFn: (workflowId: string) => del(`/apps/${appId}/workflows/${workflowId}`), | |||
| }) | |||
| } | |||
| export const usePublishWorkflow = (appId: string) => { | |||
| return useMutation({ | |||
| mutationKey: [NAME_SPACE, 'publish'], | |||
| mutationFn: (params: PublishWorkflowParams) => post<CommonResponse & { created_at: number }>(`/apps/${appId}/workflows/publish`, { | |||
| body: { | |||
| marked_name: params.title, | |||
| marked_comment: params.releaseNotes, | |||
| }, | |||
| }), | |||
| }) | |||
| } | |||
| @@ -4,7 +4,6 @@ import type { CommonResponse } from '@/models/common' | |||
| import type { | |||
| ChatRunHistoryResponse, | |||
| ConversationVariableResponse, | |||
| FetchWorkflowDraftPageResponse, | |||
| FetchWorkflowDraftResponse, | |||
| NodesDefaultConfigsResponse, | |||
| WorkflowRunHistoryResponse, | |||
| @@ -46,18 +45,10 @@ export const getLoopSingleNodeRunUrl = (isChatFlow: boolean, appId: string, node | |||
| return `apps/${appId}/${isChatFlow ? 'advanced-chat/' : ''}workflows/draft/loop/nodes/${nodeId}/run` | |||
| } | |||
| export const publishWorkflow = (url: string) => { | |||
| return post<CommonResponse & { created_at: number }>(url) | |||
| } | |||
| export const fetchPublishedWorkflow: Fetcher<FetchWorkflowDraftResponse, string> = (url) => { | |||
| return get<FetchWorkflowDraftResponse>(url) | |||
| } | |||
| export const fetchPublishedAllWorkflow: Fetcher<FetchWorkflowDraftPageResponse, string> = (url) => { | |||
| return get<FetchWorkflowDraftPageResponse>(url) | |||
| } | |||
| export const stopWorkflowRun = (url: string) => { | |||
| return post<CommonResponse>(url) | |||
| } | |||
| @@ -15,7 +15,7 @@ html[data-theme="light"] { | |||
| --color-dataset-option-card-blue-gradient: linear-gradient(90deg, #F2F4F7 0%, #F9FAFB 100%); | |||
| --color-dataset-option-card-purple-gradient: linear-gradient(90deg, #F0EEFA 0%, #F9FAFB 100%); | |||
| --color-dataset-option-card-orange-gradient: linear-gradient(90deg, #F8F2EE 0%, #F9FAFB 100%); | |||
| --color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, #FCFCFD 100%); | |||
| --color-dataset-chunk-list-mask-bg: linear-gradient(180deg, rgba(255, 255, 255, 0.00) 0%, #FFF 100%); | |||
| --mask-top2bottom-gray-50-to-transparent: linear-gradient(180deg, | |||
| rgba(200, 206, 218, 0.2) 0%, | |||
| rgba(255, 255, 255, 0) 100%); | |||
| @@ -111,14 +111,29 @@ export type FetchWorkflowDraftResponse = { | |||
| } | |||
| hash: string | |||
| updated_at: number | |||
| updated_by: { | |||
| id: string | |||
| name: string | |||
| email: string | |||
| }, | |||
| tool_published: boolean | |||
| environment_variables?: EnvironmentVariable[] | |||
| conversation_variables?: ConversationVariable[] | |||
| version: string | |||
| marked_name: string | |||
| marked_comment: string | |||
| } | |||
| export type VersionHistory = FetchWorkflowDraftResponse | |||
| export type FetchWorkflowDraftPageParams = { | |||
| appId: string | |||
| initialPage: number | |||
| limit: number | |||
| userId?: string | |||
| namedOnly?: boolean | |||
| } | |||
| export type FetchWorkflowDraftPageResponse = { | |||
| items: VersionHistory[] | |||
| has_more: boolean | |||
| @@ -323,3 +338,14 @@ export type LoopDurationMap = Record<string, number> | |||
| export type WorkflowConfigResponse = { | |||
| parallel_depth_limit: number | |||
| } | |||
| export type PublishWorkflowParams = { | |||
| title: string | |||
| releaseNotes: string | |||
| } | |||
| export type UpdateWorkflowParams = { | |||
| workflowId: string | |||
| title: string | |||
| releaseNotes: string | |||
| } | |||