feat: last run frontend (#21369)

The frontend of feat: Persist Variables for Enhanced Debugging Workflow (#20699).

Co-authored-by: jZonG <jzongcode@gmail.com>
This commit is contained in:
Joel 2025-06-24 09:10:30 +08:00 • committed by GitHub
commit 1a1bfd4048
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
122 changed files with 5888 additions and 2061 deletions

View file

@ -39,16 +39,19 @@ import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigge
export type IAppInfoProps = {
expand: boolean
onlyShowDetail?: boolean
openState?: boolean
onDetailExpand?: (expand: boolean) => void
}
const AppInfo = ({ expand }: IAppInfoProps) => {
const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailExpand }: IAppInfoProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { replace } = useRouter()
const { onPlanInfoChanged } = useProviderContext()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const [open, setOpen] = useState(false)
const [open, setOpen] = useState(openState)
const [showEditModal, setShowEditModal] = useState(false)
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
@ -193,43 +196,48 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
return (
<div>
<button
onClick={() => {
if (isCurrentWorkspaceEditor)
setOpen(v => !v)
}}
className='block w-full'
>
<div className={cn('flex rounded-lg', expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1', open && 'bg-state-base-hover', isCurrentWorkspaceEditor && 'cursor-pointer hover:bg-state-base-hover')}>
<div className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<div className='flex items-center justify-center rounded-md p-0.5'>
<div className='flex h-5 w-5 items-center justify-center'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
{!onlyShowDetail && (
<button
onClick={() => {
if (isCurrentWorkspaceEditor)
setOpen(v => !v)
}}
className='block w-full'
>
<div className={cn('flex rounded-lg', expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1', open && 'bg-state-base-hover', isCurrentWorkspaceEditor && 'cursor-pointer hover:bg-state-base-hover')}>
<div className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<div className='flex items-center justify-center rounded-md p-0.5'>
<div className='flex h-5 w-5 items-center justify-center'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</div>
</div>
{
expand && (
<div className='flex flex-col items-start gap-1'>
<div className='flex w-full'>
<div className='system-md-semibold truncate text-text-secondary'>{appDetail.name}</div>
{
expand && (
<div className='flex flex-col items-start gap-1'>
<div className='flex w-full'>
<div className='system-md-semibold truncate text-text-secondary'>{appDetail.name}</div>
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div>
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div>
</div>
)
}
</div>
</button>
)
}
</div>
</button>
)}
<ContentDialog
show={open}
onClose={() => setOpen(false)}
show={onlyShowDetail ? openState : open}
onClose={() => {
setOpen(false)
onDetailExpand?.(false)
}}
className='absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl !p-0'
>
<div className='flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4'>
@ -258,6 +266,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
className='gap-[1px]'
onClick={() => {
setOpen(false)
onDetailExpand?.(false)
setShowEditModal(true)
}}
>
@ -270,6 +279,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
className='gap-[1px]'
onClick={() => {
setOpen(false)
onDetailExpand?.(false)
setShowDuplicateModal(true)
}}>
<RiFileCopy2Line className='h-3.5 w-3.5 text-components-button-secondary-text' />
@ -308,6 +318,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
&& <div className='flex h-8 cursor-pointer items-center gap-x-1 rounded-lg p-1.5 hover:bg-state-base-hover'
onClick={() => {
setOpen(false)
onDetailExpand?.(false)
setShowImportDSLModal(true)
}}>
<RiFileUploadLine className='h-4 w-4 text-text-tertiary' />
@ -319,6 +330,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
&& <div className='flex h-8 cursor-pointer items-center gap-x-1 rounded-lg p-1.5 hover:bg-state-base-hover'
onClick={() => {
setOpen(false)
onDetailExpand?.(false)
setShowSwitchModal(true)
}}>
<RiExchange2Line className='h-4 w-4 text-text-tertiary' />
@ -345,6 +357,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
className='gap-0.5'
onClick={() => {
setOpen(false)
onDetailExpand?.(false)
setShowConfirmDelete(true)
}}
>

View file

@ -0,0 +1,125 @@
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import {
RiEqualizer2Line,
RiMenuLine,
} from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import AppIcon from '../base/app-icon'
import Divider from '../base/divider'
import AppInfo from './app-info'
import NavLink from './navLink'
import { useStore as useAppStore } from '@/app/components/app/store'
import type { NavIcon } from './navLink'
import cn from '@/utils/classnames'
type Props = {
navigation: Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
}>
}
const AppSidebarDropdown = ({ navigation }: Props) => {
const { t } = useTranslation()
const { isCurrentWorkspaceEditor } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
const [detailExpand, setDetailExpand] = useState(false)
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
if (!appDetail)
return null
return (
<>
<div className='fixed left-2 top-2 z-20'>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: -41,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div className={cn('flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-sm hover:bg-background-default-hover', open && 'bg-background-default-hover')}>
<AppIcon
size='small'
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<RiMenuLine className='h-4 w-4 text-text-tertiary' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className={cn('w-[305px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg')}>
<div className='p-2'>
<div
className={cn('flex flex-col gap-2 rounded-lg p-2 pb-2.5', isCurrentWorkspaceEditor && 'cursor-pointer hover:bg-state-base-hover')}
onClick={() => {
setDetailExpand(true)
setOpen(false)
}}
>
<div className='flex items-center justify-between self-stretch'>
<AppIcon
size='large'
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<div className='flex items-center justify-center rounded-md p-0.5'>
<div className='flex h-5 w-5 items-center justify-center'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</div>
<div className='flex flex-col items-start gap-1'>
<div className='flex w-full'>
<div className='system-md-semibold truncate text-text-secondary'>{appDetail.name}</div>
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div>
</div>
</div>
</div>
<div className='px-4'>
<Divider bgStyle='gradient' />
</div>
<nav className='space-y-0.5 px-3 pb-6 pt-4'>
{navigation.map((item, index) => {
return (
<NavLink key={index} mode='expand' iconMap={{ selected: item.selectedIcon, normal: item.icon }} name={item.name} href={item.href} />
)
})}
</nav>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
<div className='z-20'>
<AppInfo expand onlyShowDetail openState={detailExpand} onDetailExpand={setDetailExpand} />
</div>
</>
)
}
export default AppSidebarDropdown

View file

@ -1,4 +1,5 @@
import React, { useEffect } from 'react'
import React, { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import { RiLayoutLeft2Line, RiLayoutRight2Line } from '@remixicon/react'
import NavLink from './navLink'
@ -6,8 +7,10 @@ import type { NavIcon } from './navLink'
import AppBasic from './basic'
import AppInfo from './app-info'
import DatasetInfo from './dataset-info'
import AppSidebarDropdown from './app-sidebar-dropdown'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import cn from '@/utils/classnames'
export type IAppDetailNavProps = {
@ -39,6 +42,18 @@ const AppDetailNav = ({ title, desc, isExternal, icon, icon_background, navigati
setAppSiderbarExpand(state === 'expand' ? 'collapse' : 'expand')
}
// // Check if the current path is a workflow canvas & fullscreen
const pathname = usePathname()
const inWorkflowCanvas = pathname.endsWith('/workflow')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v?.type === 'workflow-canvas-maximize')
setHideHeader(v.payload)
})
useEffect(() => {
if (appSidebarExpand) {
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
@ -46,6 +61,14 @@ const AppDetailNav = ({ title, desc, isExternal, icon, icon_background, navigati
}
}, [appSidebarExpand, setAppSiderbarExpand])
if (inWorkflowCanvas && hideHeader) {
return (
<div className='flex w-0 shrink-0'>
<AppSidebarDropdown navigation={navigation} />
</div>
)
}
return (
<div
className={`

View file

@ -26,9 +26,11 @@ type Option = {
icon: React.JSX.Element
}
type FileUploaderInAttachmentProps = {
isDisabled?: boolean
fileConfig: FileUpload
}
const FileUploaderInAttachment = ({
isDisabled,
fileConfig,
}: FileUploaderInAttachmentProps) => {
const { t } = useTranslation()
@ -89,16 +91,18 @@ const FileUploaderInAttachment = ({
return (
<div>
<div className='flex items-center space-x-1'>
{options.map(renderOption)}
</div>
{!isDisabled && (
<div className='flex items-center space-x-1'>
{options.map(renderOption)}
</div>
)}
<div className='mt-1 space-y-1'>
{
files.map(file => (
<FileItem
key={file.id}
file={file}
showDeleteAction
showDeleteAction={!isDisabled}
showDownloadAction={false}
onRemove={() => handleRemoveFile(file.id)}
onReUpload={() => handleReUploadFile(file.id)}
@ -114,18 +118,20 @@ type FileUploaderInAttachmentWrapperProps = {
value?: FileEntity[]
onChange: (files: FileEntity[]) => void
fileConfig: FileUpload
isDisabled?: boolean
}
const FileUploaderInAttachmentWrapper = ({
value,
onChange,
fileConfig,
isDisabled,
}: FileUploaderInAttachmentWrapperProps) => {
return (
<FileContextProvider
value={value}
onChange={onChange}
>
<FileUploaderInAttachment fileConfig={fileConfig} />
<FileUploaderInAttachment isDisabled={isDisabled} fileConfig={fileConfig} />
</FileContextProvider>
)
}

View file

@ -154,7 +154,7 @@ export const getProcessedFilesFromResponse = (files: FileResponse[]) => {
transferMethod: fileItem.transfer_method,
supportFileType: fileItem.type,
uploadedId: fileItem.upload_file_id || fileItem.related_id,
url: fileItem.url,
url: fileItem.url || fileItem.remote_url,
}
})
}

View file

@ -9,30 +9,34 @@ type Item = {
isRight?: boolean
icon?: React.ReactNode
extra?: React.ReactNode
disabled?: boolean
}
export type ITabHeaderProps = {
items: Item[]
value: string
itemClassName?: string
onChange: (value: string) => void
}
const TabHeader: FC<ITabHeaderProps> = ({
items,
value,
itemClassName,
onChange,
}) => {
const renderItem = ({ id, name, icon, extra }: Item) => (
const renderItem = ({ id, name, icon, extra, disabled }: Item) => (
<div
key={id}
className={cn(
'system-md-semibold relative flex cursor-pointer items-center border-b-2 border-transparent pb-2 pt-2.5',
id === value ? 'border-components-tab-active text-text-primary' : 'text-text-tertiary',
disabled && 'cursor-not-allowed opacity-30',
)}
onClick={() => onChange(id)}
onClick={() => !disabled && onChange(id)}
>
{icon || ''}
<div className='ml-2'>{name}</div>
<div className={cn('ml-2', itemClassName)}>{name}</div>
{extra || ''}
</div>
)

View file

@ -1,6 +1,8 @@
'use client'
import React, { useState } from 'react'
import { usePathname } from 'next/navigation'
import s from './index.module.css'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import classNames from '@/utils/classnames'
type HeaderWrapperProps = {
@ -12,6 +14,19 @@ const HeaderWrapper = ({
}: HeaderWrapperProps) => {
const pathname = usePathname()
const isBordered = ['/apps', '/datasets', '/datasets/create', '/tools'].includes(pathname)
// // Check if the current path is a workflow canvas & fullscreen
const inWorkflowCanvas = pathname.endsWith('/workflow')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v?.type === 'workflow-canvas-maximize')
setHideHeader(v.payload)
})
if (hideHeader && inWorkflowCanvas)
return null
return (
<div className={classNames(

View file

@ -0,0 +1,68 @@
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
import { useWorkflowStore } from '../../workflow/store'
import { useStoreApi } from 'reactflow'
import type { Node } from '@/app/components/workflow/types'
import { fetchAllInspectVars } from '@/service/workflow'
import { useInvalidateConversationVarValues, useInvalidateSysVarValues } from '@/service/use-workflow'
import { useNodesInteractionsWithoutSync } from '../../workflow/hooks/use-nodes-interactions-without-sync'
const useSetWorkflowVarsWithValue = () => {
const workflowStore = useWorkflowStore()
const { setNodesWithInspectVars, appId } = workflowStore.getState()
const store = useStoreApi()
const invalidateConversationVarValues = useInvalidateConversationVarValues(appId)
const invalidateSysVarValues = useInvalidateSysVarValues(appId)
const { handleCancelAllNodeSuccessStatus } = useNodesInteractionsWithoutSync()
const setInspectVarsToStore = (inspectVars: VarInInspect[]) => {
const { getNodes } = store.getState()
const nodeArr = getNodes()
const nodesKeyValue: Record<string, Node> = {}
nodeArr.forEach((node) => {
nodesKeyValue[node.id] = node
})
const withValueNodeIds: Record<string, boolean> = {}
inspectVars.forEach((varItem) => {
const nodeId = varItem.selector[0]
const node = nodesKeyValue[nodeId]
if (!node)
return
withValueNodeIds[nodeId] = true
})
const withValueNodes = Object.keys(withValueNodeIds).map((nodeId) => {
return nodesKeyValue[nodeId]
})
const res: NodeWithVar[] = withValueNodes.map((node) => {
const nodeId = node.id
const varsUnderTheNode = inspectVars.filter((varItem) => {
return varItem.selector[0] === nodeId
})
const nodeWithVar = {
nodeId,
nodePayload: node.data,
nodeType: node.data.type,
title: node.data.title,
vars: varsUnderTheNode,
isSingRunRunning: false,
isValueFetched: false,
}
return nodeWithVar
})
setNodesWithInspectVars(res)
}
const fetchInspectVars = async () => {
invalidateConversationVarValues()
invalidateSysVarValues()
const data = await fetchAllInspectVars(appId)
setInspectVarsToStore(data)
handleCancelAllNodeSuccessStatus() // to make sure clear node output show the unset status
}
return {
fetchInspectVars,
}
}
export default useSetWorkflowVarsWithValue

View file

@ -17,7 +17,6 @@ import {
} from '@/service/workflow'
import type { FetchWorkflowDraftResponse } from '@/types/workflow'
import { useWorkflowConfig } from '@/service/use-workflow'
export const useWorkflowInit = () => {
const workflowStore = useWorkflowStore()
const {

View file

@ -19,6 +19,8 @@ import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player
import type { VersionHistory } from '@/types/workflow'
import { noop } from 'lodash-es'
import { useNodesSyncDraft } from './use-nodes-sync-draft'
import { useInvalidAllLastRun } from '@/service/use-workflow'
import useSetWorkflowVarsWithValue from './use-fetch-workflow-inspect-vars'
export const useWorkflowRun = () => {
const store = useStoreApi()
@ -28,6 +30,9 @@ export const useWorkflowRun = () => {
const { doSyncWorkflowDraft } = useNodesSyncDraft()
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
const pathname = usePathname()
const appId = useAppStore.getState().appDetail?.id
const invalidAllLastRun = useInvalidAllLastRun(appId as string)
const { fetchInspectVars } = useSetWorkflowVarsWithValue()
const {
handleWorkflowStarted,
@ -140,11 +145,13 @@ export const useWorkflowRun = () => {
clientHeight,
} = workflowContainer!
const isInWorkflowDebug = appDetail?.mode === 'workflow'
let url = ''
if (appDetail?.mode === 'advanced-chat')
url = `/apps/${appDetail.id}/advanced-chat/workflows/draft/run`
if (appDetail?.mode === 'workflow')
if (isInWorkflowDebug)
url = `/apps/${appDetail.id}/workflows/draft/run`
const {
@ -189,6 +196,10 @@ export const useWorkflowRun = () => {
if (onWorkflowFinished)
onWorkflowFinished(params)
if (isInWorkflowDebug) {
fetchInspectVars()
invalidAllLastRun()
}
},
onError: (params) => {
handleWorkflowFailed()
@ -292,26 +303,7 @@ export const useWorkflowRun = () => {
...restCallback,
},
)
}, [
store,
workflowStore,
doSyncWorkflowDraft,
handleWorkflowStarted,
handleWorkflowFinished,
handleWorkflowFailed,
handleWorkflowNodeStarted,
handleWorkflowNodeFinished,
handleWorkflowNodeIterationStarted,
handleWorkflowNodeIterationNext,
handleWorkflowNodeIterationFinished,
handleWorkflowNodeLoopStarted,
handleWorkflowNodeLoopNext,
handleWorkflowNodeLoopFinished,
handleWorkflowNodeRetry,
handleWorkflowTextChunk,
handleWorkflowTextReplace,
handleWorkflowAgentLog,
pathname],
}, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace],
)
const handleStopRun = useCallback((taskId: string) => {

View file

@ -31,6 +31,7 @@ type NodesExtraData = {
getAvailablePrevNodes: (isChatMode: boolean) => BlockEnum[]
getAvailableNextNodes: (isChatMode: boolean) => BlockEnum[]
checkValid: any
defaultRunInputData?: Record<string, any>
}
export const NODES_EXTRA_DATA: Record<BlockEnum, NodesExtraData> = {
[BlockEnum.Start]: {
@ -68,6 +69,7 @@ export const NODES_EXTRA_DATA: Record<BlockEnum, NodesExtraData> = {
getAvailablePrevNodes: LLMDefault.getAvailablePrevNodes,
getAvailableNextNodes: LLMDefault.getAvailableNextNodes,
checkValid: LLMDefault.checkValid,
defaultRunInputData: LLMDefault.defaultRunInputData,
},
[BlockEnum.KnowledgeRetrieval]: {
author: 'Dify',

View file

@ -17,6 +17,8 @@ import {
import Toast from '../../base/toast'
import RestoringTitle from './restoring-title'
import Button from '@/app/components/base/button'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useInvalidAllLastRun } from '@/service/use-workflow'
export type HeaderInRestoringProps = {
onRestoreSettled?: () => void
@ -26,6 +28,12 @@ const HeaderInRestoring = ({
}: HeaderInRestoringProps) => {
const { t } = useTranslation()
const workflowStore = useWorkflowStore()
const appDetail = useAppStore.getState().appDetail
const invalidAllLastRun = useInvalidAllLastRun(appDetail!.id)
const {
deleteAllInspectVars,
} = workflowStore.getState()
const currentVersion = useStore(s => s.currentVersion)
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
@ -61,7 +69,9 @@ const HeaderInRestoring = ({
onRestoreSettled?.()
},
})
}, [handleSyncWorkflowDraft, workflowStore, setShowWorkflowVersionHistoryPanel, onRestoreSettled, t])
deleteAllInspectVars()
invalidAllLastRun()
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
return (
<>

View file

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

View file

@ -19,6 +19,8 @@ import cn from '@/utils/classnames'
import {
StopCircle,
} from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
const RunMode = memo(() => {
const { t } = useTranslation()
@ -27,6 +29,16 @@ const RunMode = memo(() => {
const workflowRunningData = useStore(s => s.workflowRunningData)
const isRunning = workflowRunningData?.result.status === WorkflowRunningStatus.Running
const handleStop = () => {
handleStopRun(workflowRunningData?.task_id || '')
}
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v.type === EVENT_WORKFLOW_STOP)
handleStop()
})
return (
<>
<div
@ -59,7 +71,7 @@ const RunMode = memo(() => {
isRunning && (
<div
className='ml-0.5 flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-black/5'
onClick={() => handleStopRun(workflowRunningData?.task_id || '')}
onClick={handleStop}
>
<StopCircle className='h-4 w-4 text-components-button-ghost-text' />
</div>

View file

@ -0,0 +1,241 @@
import { fetchNodeInspectVars } from '@/service/workflow'
import { useStore, useWorkflowStore } from '../store'
import type { ValueSelector } from '../types'
import type { VarInInspect } from '@/types/workflow'
import { VarInInspectType } from '@/types/workflow'
import {
useConversationVarValues,
useDeleteAllInspectorVars,
useDeleteInspectVar,
useDeleteNodeInspectorVars,
useEditInspectorVar,
useInvalidateConversationVarValues,
useInvalidateSysVarValues,
useLastRun,
useResetConversationVar,
useResetToLastRunValue,
useSysVarValues,
} from '@/service/use-workflow'
import { useCallback, useEffect, useState } from 'react'
import { isConversationVar, isENV, isSystemVar } from '../nodes/_base/components/variable/utils'
import produce from 'immer'
import type { Node } from '@/app/components/workflow/types'
import { useNodesInteractionsWithoutSync } from './use-nodes-interactions-without-sync'
import { useEdgesInteractionsWithoutSync } from './use-edges-interactions-without-sync'
const useInspectVarsCrud = () => {
const workflowStore = useWorkflowStore()
const nodesWithInspectVars = useStore(s => s.nodesWithInspectVars)
const {
appId,
setNodeInspectVars,
setInspectVarValue,
renameInspectVarName: renameInspectVarNameInStore,
deleteAllInspectVars: deleteAllInspectVarsInStore,
deleteNodeInspectVars: deleteNodeInspectVarsInStore,
deleteInspectVar: deleteInspectVarInStore,
setNodesWithInspectVars,
resetToLastRunVar: resetToLastRunVarInStore,
} = workflowStore.getState()
const { data: conversationVars } = useConversationVarValues(appId)
const invalidateConversationVarValues = useInvalidateConversationVarValues(appId)
const { mutateAsync: doResetConversationVar } = useResetConversationVar(appId)
const { mutateAsync: doResetToLastRunValue } = useResetToLastRunValue(appId)
const { data: systemVars } = useSysVarValues(appId)
const invalidateSysVarValues = useInvalidateSysVarValues(appId)
const { mutateAsync: doDeleteAllInspectorVars } = useDeleteAllInspectorVars(appId)
const { mutate: doDeleteNodeInspectorVars } = useDeleteNodeInspectorVars(appId)
const { mutate: doDeleteInspectVar } = useDeleteInspectVar(appId)
const { mutateAsync: doEditInspectorVar } = useEditInspectorVar(appId)
const { handleCancelNodeSuccessStatus } = useNodesInteractionsWithoutSync()
const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync()
const getNodeInspectVars = useCallback((nodeId: string) => {
const node = nodesWithInspectVars.find(node => node.nodeId === nodeId)
return node
}, [nodesWithInspectVars])
const getVarId = useCallback((nodeId: string, varName: string) => {
const node = getNodeInspectVars(nodeId)
if (!node)
return undefined
const varId = node.vars.find((varItem) => {
return varItem.selector[1] === varName
})?.id
return varId
}, [getNodeInspectVars])
const getInspectVar = useCallback((nodeId: string, name: string): VarInInspect | undefined => {
const node = getNodeInspectVars(nodeId)
if (!node)
return undefined
const variable = node.vars.find((varItem) => {
return varItem.name === name
})
return variable
}, [getNodeInspectVars])
const hasSetInspectVar = useCallback((nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => {
const isEnv = isENV([nodeId])
if (isEnv) // always have value
return true
const isSys = isSystemVar([nodeId])
if (isSys)
return sysVars.some(varItem => varItem.selector?.[1]?.replace('sys.', '') === name)
const isChatVar = isConversationVar([nodeId])
if (isChatVar)
return conversationVars.some(varItem => varItem.selector?.[1] === name)
return getInspectVar(nodeId, name) !== undefined
}, [getInspectVar])
const hasNodeInspectVars = useCallback((nodeId: string) => {
return !!getNodeInspectVars(nodeId)
}, [getNodeInspectVars])
const fetchInspectVarValue = async (selector: ValueSelector) => {
const nodeId = selector[0]
const isSystemVar = nodeId === 'sys'
const isConversationVar = nodeId === 'conversation'
if (isSystemVar) {
invalidateSysVarValues()
return
}
if (isConversationVar) {
invalidateConversationVarValues()
return
}
const vars = await fetchNodeInspectVars(appId, nodeId)
setNodeInspectVars(nodeId, vars)
}
// after last run would call this
const appendNodeInspectVars = (nodeId: string, payload: VarInInspect[], allNodes: Node[]) => {
const nodes = produce(nodesWithInspectVars, (draft) => {
const nodeInfo = allNodes.find(node => node.id === nodeId)
if (nodeInfo) {
const index = draft.findIndex(node => node.nodeId === nodeId)
if (index === -1) {
draft.push({
nodeId,
nodeType: nodeInfo.data.type,
title: nodeInfo.data.title,
vars: payload,
})
}
else {
draft[index].vars = payload
}
}
})
setNodesWithInspectVars(nodes)
handleCancelNodeSuccessStatus(nodeId)
}
const hasNodeInspectVar = (nodeId: string, varId: string) => {
const targetNode = nodesWithInspectVars.find(item => item.nodeId === nodeId)
if(!targetNode || !targetNode.vars)
return false
return targetNode.vars.some(item => item.id === varId)
}
const deleteInspectVar = async (nodeId: string, varId: string) => {
if(hasNodeInspectVar(nodeId, varId)) {
await doDeleteInspectVar(varId)
deleteInspectVarInStore(nodeId, varId)
}
}
const resetConversationVar = async (varId: string) => {
await doResetConversationVar(varId)
invalidateConversationVarValues()
}
const deleteNodeInspectorVars = async (nodeId: string) => {
if (hasNodeInspectVars(nodeId)) {
await doDeleteNodeInspectorVars(nodeId)
deleteNodeInspectVarsInStore(nodeId)
}
}
const deleteAllInspectorVars = async () => {
await doDeleteAllInspectorVars()
await invalidateConversationVarValues()
await invalidateSysVarValues()
deleteAllInspectVarsInStore()
handleEdgeCancelRunningStatus()
}
const editInspectVarValue = useCallback(async (nodeId: string, varId: string, value: any) => {
await doEditInspectorVar({
varId,
value,
})
setInspectVarValue(nodeId, varId, value)
if (nodeId === VarInInspectType.conversation)
invalidateConversationVarValues()
if (nodeId === VarInInspectType.system)
invalidateSysVarValues()
}, [doEditInspectorVar, invalidateConversationVarValues, invalidateSysVarValues, setInspectVarValue])
const [currNodeId, setCurrNodeId] = useState<string | null>(null)
const [currEditVarId, setCurrEditVarId] = useState<string | null>(null)
const { data } = useLastRun(appId, currNodeId || '', !!currNodeId)
useEffect(() => {
if (data && currNodeId && currEditVarId) {
const inspectVar = getNodeInspectVars(currNodeId)?.vars?.find(item => item.id === currEditVarId)
resetToLastRunVarInStore(currNodeId, currEditVarId, data.outputs?.[inspectVar?.selector?.[1] || ''])
}
}, [data, currNodeId, currEditVarId, getNodeInspectVars, editInspectVarValue, resetToLastRunVarInStore])
const renameInspectVarName = async (nodeId: string, oldName: string, newName: string) => {
const varId = getVarId(nodeId, oldName)
if (!varId)
return
const newSelector = [nodeId, newName]
await doEditInspectorVar({
varId,
name: newName,
})
renameInspectVarNameInStore(nodeId, varId, newSelector)
}
const isInspectVarEdited = useCallback((nodeId: string, name: string) => {
const inspectVar = getInspectVar(nodeId, name)
if (!inspectVar)
return false
return inspectVar.edited
}, [getInspectVar])
const resetToLastRunVar = async (nodeId: string, varId: string) => {
await doResetToLastRunValue(varId)
setCurrNodeId(nodeId)
setCurrEditVarId(varId)
}
return {
conversationVars: conversationVars || [],
systemVars: systemVars || [],
nodesWithInspectVars,
hasNodeInspectVars,
hasSetInspectVar,
fetchInspectVarValue,
editInspectVarValue,
renameInspectVarName,
appendNodeInspectVars,
deleteInspectVar,
deleteNodeInspectorVars,
deleteAllInspectorVars,
isInspectVarEdited,
resetToLastRunVar,
invalidateSysVarValues,
resetConversationVar,
invalidateConversationVarValues,
}
}
export default useInspectVarsCrud

View file

@ -1,6 +1,7 @@
import { useCallback } from 'react'
import produce from 'immer'
import { useStoreApi } from 'reactflow'
import { NodeRunningStatus } from '../types'
export const useNodesInteractionsWithoutSync = () => {
const store = useStoreApi()
@ -21,7 +22,41 @@ export const useNodesInteractionsWithoutSync = () => {
setNodes(newNodes)
}, [store])
const handleCancelAllNodeSuccessStatus = useCallback(() => {
const {
getNodes,
setNodes,
} = store.getState()
const nodes = getNodes()
const newNodes = produce(nodes, (draft) => {
draft.forEach((node) => {
if(node.data._runningStatus === NodeRunningStatus.Succeeded)
node.data._runningStatus = undefined
})
})
setNodes(newNodes)
}, [store])
const handleCancelNodeSuccessStatus = useCallback((nodeId: string) => {
const {
getNodes,
setNodes,
} = store.getState()
const newNodes = produce(getNodes(), (draft) => {
const node = draft.find(n => n.id === nodeId)
if (node && node.data._runningStatus === NodeRunningStatus.Succeeded) {
node.data._runningStatus = undefined
node.data._waitingRun = false
}
})
setNodes(newNodes)
}, [store])
return {
handleNodeCancelRunningStatus,
handleCancelAllNodeSuccessStatus,
handleCancelNodeSuccessStatus,
}
}

View file

@ -60,6 +60,7 @@ import {
useWorkflowReadOnly,
} from './use-workflow'
import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history'
import useInspectVarsCrud from './use-inspect-vars-crud'
export const useNodesInteractions = () => {
const { t } = useTranslation()
@ -288,7 +289,9 @@ export const useNodesInteractions = () => {
setEdges(newEdges)
}, [store, workflowStore, getNodesReadOnly])
const handleNodeSelect = useCallback((nodeId: string, cancelSelection?: boolean) => {
const handleNodeSelect = useCallback((nodeId: string, cancelSelection?: boolean, initShowLastRunTab?: boolean) => {
if(initShowLastRunTab)
workflowStore.setState({ initShowLastRunTab: true })
const {
getNodes,
setNodes,
@ -530,6 +533,8 @@ export const useNodesInteractions = () => {
setEnteringNodePayload(undefined)
}, [store, handleNodeConnect, getNodesReadOnly, workflowStore, reactflow])
const { deleteNodeInspectorVars } = useInspectVarsCrud()
const handleNodeDelete = useCallback((nodeId: string) => {
if (getNodesReadOnly())
return
@ -551,6 +556,7 @@ export const useNodesInteractions = () => {
if (currentNode.data.type === BlockEnum.Start)
return
deleteNodeInspectorVars(nodeId)
if (currentNode.data.type === BlockEnum.Iteration) {
const iterationChildren = nodes.filter(node => node.parentId === currentNode.id)
@ -655,7 +661,7 @@ export const useNodesInteractions = () => {
else
saveStateToHistory(WorkflowHistoryEvent.NodeDelete)
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory, workflowStore, t])
}, [getNodesReadOnly, store, deleteNodeInspectorVars, handleSyncWorkflowDraft, saveStateToHistory, workflowStore, t])
const handleNodeAdd = useCallback<OnNodeAdd>((
{

View file

@ -11,6 +11,7 @@ import {
useEdgesInteractions,
useNodesInteractions,
useNodesSyncDraft,
useWorkflowCanvasMaximize,
useWorkflowMoveMode,
useWorkflowOrganize,
useWorkflowStartRun,
@ -35,6 +36,7 @@ export const useShortcuts = (): void => {
handleModePointer,
} = useWorkflowMoveMode()
const { handleLayout } = useWorkflowOrganize()
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
const {
zoomTo,
@ -145,6 +147,16 @@ export const useShortcuts = (): void => {
}
}, { exactMatch: true, useCapture: true })
useKeyPress('f', (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()
handleToggleMaximizeCanvas()
}
}, {
exactMatch: true,
useCapture: true,
})
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.1`, (e) => {
if (shouldHandleShortcut(e)) {
e.preventDefault()

View file

@ -401,3 +401,29 @@ export const useDSL = () => {
handleExportDSL,
}
}
export const useWorkflowCanvasMaximize = () => {
const { eventEmitter } = useEventEmitterContextContext()
const maximizeCanvas = useStore(s => s.maximizeCanvas)
const setMaximizeCanvas = useStore(s => s.setMaximizeCanvas)
const {
getNodesReadOnly,
} = useNodesReadOnly()
const handleToggleMaximizeCanvas = useCallback(() => {
if (getNodesReadOnly())
return
setMaximizeCanvas(!maximizeCanvas)
localStorage.setItem('workflow-canvas-maximize', String(!maximizeCanvas))
eventEmitter?.emit({
type: 'workflow-canvas-maximize',
payload: !maximizeCanvas,
} as any)
}, [eventEmitter, getNodesReadOnly, maximizeCanvas, setMaximizeCanvas])
return {
handleToggleMaximizeCanvas,
}
}

View file

@ -59,10 +59,6 @@ export const useWorkflow = () => {
const store = useStoreApi()
const workflowStore = useWorkflowStore()
const nodesExtraData = useNodesExtraData()
const setPanelWidth = useCallback((width: number) => {
localStorage.setItem('workflow-node-panel-width', `${width}`)
workflowStore.setState({ panelWidth: width })
}, [workflowStore])
const getTreeLeafNodes = useCallback((nodeId: string) => {
const {
@ -399,7 +395,6 @@ export const useWorkflow = () => {
}, [store])
return {
setPanelWidth,
getTreeLeafNodes,
getBeforeNodesInSameBranch,
getBeforeNodesInSameBranchIncludeParent,
@ -497,6 +492,8 @@ export const useToolIcon = (data: Node['data']) => {
const customTools = useStore(s => s.customTools)
const workflowTools = useStore(s => s.workflowTools)
const toolIcon = useMemo(() => {
if(!data)
return ''
if (data.type === BlockEnum.Tool) {
let targetTools = buildInTools
if (data.provider_type === CollectionType.builtIn)

Some files were not shown because too many files have changed in this diff Show more