dify
This commit is contained in:
@@ -0,0 +1,341 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { act, render } from '@testing-library/react'
|
||||
import { useTriggerStatusStore } from '../store/trigger-status'
|
||||
import { isTriggerNode } from '../types'
|
||||
import type { BlockEnum } from '../types'
|
||||
import type { EntryNodeStatus } from '../store/trigger-status'
|
||||
|
||||
// Mock the isTriggerNode function while preserving BlockEnum
|
||||
jest.mock('../types', () => ({
|
||||
...jest.requireActual('../types'),
|
||||
isTriggerNode: jest.fn(),
|
||||
}))
|
||||
|
||||
const mockIsTriggerNode = isTriggerNode as jest.MockedFunction<typeof isTriggerNode>
|
||||
|
||||
// Test component that mimics BaseNode's usage pattern
|
||||
const TestTriggerNode: React.FC<{
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
}> = ({ nodeId, nodeType }) => {
|
||||
const triggerStatus = useTriggerStatusStore(state =>
|
||||
mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled',
|
||||
)
|
||||
|
||||
return (
|
||||
<div data-testid={`node-${nodeId}`} data-status={triggerStatus}>
|
||||
Status: {triggerStatus}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Test component that mimics TriggerCard's usage pattern
|
||||
const TestTriggerController: React.FC = () => {
|
||||
const { setTriggerStatus, setTriggerStatuses } = useTriggerStatusStore()
|
||||
|
||||
const handleToggle = (nodeId: string, enabled: boolean) => {
|
||||
const newStatus = enabled ? 'enabled' : 'disabled'
|
||||
setTriggerStatus(nodeId, newStatus)
|
||||
}
|
||||
|
||||
const handleBatchUpdate = (statuses: Record<string, EntryNodeStatus>) => {
|
||||
setTriggerStatuses(statuses)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
data-testid="toggle-node-1"
|
||||
onClick={() => handleToggle('node-1', true)}
|
||||
>
|
||||
Enable Node 1
|
||||
</button>
|
||||
<button
|
||||
data-testid="toggle-node-2"
|
||||
onClick={() => handleToggle('node-2', false)}
|
||||
>
|
||||
Disable Node 2
|
||||
</button>
|
||||
<button
|
||||
data-testid="batch-update"
|
||||
onClick={() => handleBatchUpdate({
|
||||
'node-1': 'disabled',
|
||||
'node-2': 'enabled',
|
||||
'node-3': 'enabled',
|
||||
})}
|
||||
>
|
||||
Batch Update
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Trigger Status Synchronization Integration', () => {
|
||||
beforeEach(() => {
|
||||
// Clear store state
|
||||
act(() => {
|
||||
const store = useTriggerStatusStore.getState()
|
||||
store.clearTriggerStatuses()
|
||||
})
|
||||
|
||||
// Reset mocks
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('Real-time Status Synchronization', () => {
|
||||
it('should sync status changes between trigger controller and nodes', () => {
|
||||
mockIsTriggerNode.mockReturnValue(true)
|
||||
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<TestTriggerController />
|
||||
<TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" />
|
||||
<TestTriggerNode nodeId="node-2" nodeType="trigger-schedule" />
|
||||
</>,
|
||||
)
|
||||
|
||||
// Initial state - should be 'disabled' by default
|
||||
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled')
|
||||
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled')
|
||||
|
||||
// Enable node-1
|
||||
act(() => {
|
||||
getByTestId('toggle-node-1').click()
|
||||
})
|
||||
|
||||
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled')
|
||||
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled')
|
||||
|
||||
// Disable node-2 (should remain disabled)
|
||||
act(() => {
|
||||
getByTestId('toggle-node-2').click()
|
||||
})
|
||||
|
||||
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled')
|
||||
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled')
|
||||
})
|
||||
|
||||
it('should handle batch status updates correctly', () => {
|
||||
mockIsTriggerNode.mockReturnValue(true)
|
||||
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<TestTriggerController />
|
||||
<TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" />
|
||||
<TestTriggerNode nodeId="node-2" nodeType="trigger-schedule" />
|
||||
<TestTriggerNode nodeId="node-3" nodeType="trigger-plugin" />
|
||||
</>,
|
||||
)
|
||||
|
||||
// Initial state
|
||||
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled')
|
||||
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'disabled')
|
||||
expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'disabled')
|
||||
|
||||
// Batch update
|
||||
act(() => {
|
||||
getByTestId('batch-update').click()
|
||||
})
|
||||
|
||||
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled')
|
||||
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'enabled')
|
||||
expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'enabled')
|
||||
})
|
||||
|
||||
it('should handle mixed node types (trigger vs non-trigger)', () => {
|
||||
// Mock different node types
|
||||
mockIsTriggerNode.mockImplementation((nodeType: string) => {
|
||||
return nodeType.startsWith('trigger-')
|
||||
})
|
||||
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<TestTriggerController />
|
||||
<TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" />
|
||||
<TestTriggerNode nodeId="node-2" nodeType="start" />
|
||||
<TestTriggerNode nodeId="node-3" nodeType="llm" />
|
||||
</>,
|
||||
)
|
||||
|
||||
// Trigger node should use store status, non-trigger nodes should be 'enabled'
|
||||
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'disabled') // trigger node
|
||||
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'enabled') // start node
|
||||
expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'enabled') // llm node
|
||||
|
||||
// Update trigger node status
|
||||
act(() => {
|
||||
getByTestId('toggle-node-1').click()
|
||||
})
|
||||
|
||||
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled') // updated
|
||||
expect(getByTestId('node-node-2')).toHaveAttribute('data-status', 'enabled') // unchanged
|
||||
expect(getByTestId('node-node-3')).toHaveAttribute('data-status', 'enabled') // unchanged
|
||||
})
|
||||
})
|
||||
|
||||
describe('Store State Management', () => {
|
||||
it('should maintain state consistency across multiple components', () => {
|
||||
mockIsTriggerNode.mockReturnValue(true)
|
||||
|
||||
// Render multiple instances of the same node
|
||||
const { getByTestId, rerender } = render(
|
||||
<>
|
||||
<TestTriggerController />
|
||||
<TestTriggerNode nodeId="shared-node" nodeType="trigger-webhook" />
|
||||
</>,
|
||||
)
|
||||
|
||||
// Update status
|
||||
act(() => {
|
||||
getByTestId('toggle-node-1').click() // This updates node-1, not shared-node
|
||||
})
|
||||
|
||||
// Add another component with the same nodeId
|
||||
rerender(
|
||||
<>
|
||||
<TestTriggerController />
|
||||
<TestTriggerNode nodeId="shared-node" nodeType="trigger-webhook" />
|
||||
<TestTriggerNode nodeId="shared-node" nodeType="trigger-webhook" />
|
||||
</>,
|
||||
)
|
||||
|
||||
// Both components should show the same status
|
||||
const nodes = document.querySelectorAll('[data-testid="node-shared-node"]')
|
||||
expect(nodes).toHaveLength(2)
|
||||
nodes.forEach((node) => {
|
||||
expect(node).toHaveAttribute('data-status', 'disabled')
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle rapid status changes correctly', () => {
|
||||
mockIsTriggerNode.mockReturnValue(true)
|
||||
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<TestTriggerController />
|
||||
<TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" />
|
||||
</>,
|
||||
)
|
||||
|
||||
// Rapid consecutive updates
|
||||
act(() => {
|
||||
// Multiple rapid clicks
|
||||
getByTestId('toggle-node-1').click() // enable
|
||||
getByTestId('toggle-node-2').click() // disable (different node)
|
||||
getByTestId('toggle-node-1').click() // enable again
|
||||
})
|
||||
|
||||
// Should reflect the final state
|
||||
expect(getByTestId('node-node-1')).toHaveAttribute('data-status', 'enabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Error Scenarios', () => {
|
||||
it('should handle non-existent node IDs gracefully', () => {
|
||||
mockIsTriggerNode.mockReturnValue(true)
|
||||
|
||||
const { getByTestId } = render(
|
||||
<TestTriggerNode nodeId="non-existent-node" nodeType="trigger-webhook" />,
|
||||
)
|
||||
|
||||
// Should default to 'disabled' for non-existent nodes
|
||||
expect(getByTestId('node-non-existent-node')).toHaveAttribute('data-status', 'disabled')
|
||||
})
|
||||
|
||||
it('should handle component unmounting gracefully', () => {
|
||||
mockIsTriggerNode.mockReturnValue(true)
|
||||
|
||||
const { getByTestId, unmount } = render(
|
||||
<>
|
||||
<TestTriggerController />
|
||||
<TestTriggerNode nodeId="node-1" nodeType="trigger-webhook" />
|
||||
</>,
|
||||
)
|
||||
|
||||
// Update status
|
||||
act(() => {
|
||||
getByTestId('toggle-node-1').click()
|
||||
})
|
||||
|
||||
// Unmount components
|
||||
expect(() => unmount()).not.toThrow()
|
||||
|
||||
// Store should still maintain the state
|
||||
const store = useTriggerStatusStore.getState()
|
||||
expect(store.triggerStatuses['node-1']).toBe('enabled')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Performance Optimization', () => {
|
||||
// Component that uses optimized selector with useCallback
|
||||
const OptimizedTriggerNode: React.FC<{
|
||||
nodeId: string
|
||||
nodeType: string
|
||||
}> = ({ nodeId, nodeType }) => {
|
||||
const triggerStatusSelector = useCallback((state: any) =>
|
||||
mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses[nodeId] || 'disabled') : 'enabled',
|
||||
[nodeId, nodeType],
|
||||
)
|
||||
const triggerStatus = useTriggerStatusStore(triggerStatusSelector)
|
||||
|
||||
return (
|
||||
<div data-testid={`optimized-node-${nodeId}`} data-status={triggerStatus}>
|
||||
Status: {triggerStatus}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
it('should work correctly with optimized selector using useCallback', () => {
|
||||
mockIsTriggerNode.mockImplementation(nodeType => nodeType === 'trigger-webhook')
|
||||
|
||||
const { getByTestId } = render(
|
||||
<>
|
||||
<OptimizedTriggerNode nodeId="node-1" nodeType="trigger-webhook" />
|
||||
<OptimizedTriggerNode nodeId="node-2" nodeType="start" />
|
||||
<TestTriggerController />
|
||||
</>,
|
||||
)
|
||||
|
||||
// Initial state
|
||||
expect(getByTestId('optimized-node-node-1')).toHaveAttribute('data-status', 'disabled')
|
||||
expect(getByTestId('optimized-node-node-2')).toHaveAttribute('data-status', 'enabled')
|
||||
|
||||
// Update status via controller
|
||||
act(() => {
|
||||
getByTestId('toggle-node-1').click()
|
||||
})
|
||||
|
||||
// Verify optimized component updates correctly
|
||||
expect(getByTestId('optimized-node-node-1')).toHaveAttribute('data-status', 'enabled')
|
||||
expect(getByTestId('optimized-node-node-2')).toHaveAttribute('data-status', 'enabled')
|
||||
})
|
||||
|
||||
it('should handle selector dependency changes correctly', () => {
|
||||
mockIsTriggerNode.mockImplementation(nodeType => nodeType === 'trigger-webhook')
|
||||
|
||||
const TestComponent: React.FC<{ nodeType: string }> = ({ nodeType }) => {
|
||||
const triggerStatusSelector = useCallback((state: any) =>
|
||||
mockIsTriggerNode(nodeType as BlockEnum) ? (state.triggerStatuses['test-node'] || 'disabled') : 'enabled',
|
||||
['test-node', nodeType], // Dependencies should match implementation
|
||||
)
|
||||
const status = useTriggerStatusStore(triggerStatusSelector)
|
||||
return <div data-testid="test-component" data-status={status} />
|
||||
}
|
||||
|
||||
const { getByTestId, rerender } = render(<TestComponent nodeType="trigger-webhook" />)
|
||||
|
||||
// Initial trigger node
|
||||
expect(getByTestId('test-component')).toHaveAttribute('data-status', 'disabled')
|
||||
|
||||
// Set status for the node
|
||||
act(() => {
|
||||
useTriggerStatusStore.getState().setTriggerStatus('test-node', 'enabled')
|
||||
})
|
||||
expect(getByTestId('test-component')).toHaveAttribute('data-status', 'enabled')
|
||||
|
||||
// Change node type to non-trigger - should return 'enabled' regardless of store
|
||||
rerender(<TestComponent nodeType="start" />)
|
||||
expect(getByTestId('test-component')).toHaveAttribute('data-status', 'enabled')
|
||||
})
|
||||
})
|
||||
})
|
||||
179
dify/web/app/components/workflow/block-icon.tsx
Normal file
179
dify/web/app/components/workflow/block-icon.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo } from 'react'
|
||||
import { BlockEnum } from './types'
|
||||
import {
|
||||
Agent,
|
||||
Answer,
|
||||
Assigner,
|
||||
Code,
|
||||
Datasource,
|
||||
DocsExtractor,
|
||||
End,
|
||||
Home,
|
||||
Http,
|
||||
IfElse,
|
||||
Iteration,
|
||||
KnowledgeBase,
|
||||
KnowledgeRetrieval,
|
||||
ListFilter,
|
||||
Llm,
|
||||
Loop,
|
||||
LoopEnd,
|
||||
ParameterExtractor,
|
||||
QuestionClassifier,
|
||||
Schedule,
|
||||
TemplatingTransform,
|
||||
VariableX,
|
||||
WebhookLine,
|
||||
} from '@/app/components/base/icons/src/vender/workflow'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type BlockIconProps = {
|
||||
type: BlockEnum
|
||||
size?: string
|
||||
className?: string
|
||||
toolIcon?: string | { content: string; background: string }
|
||||
}
|
||||
const ICON_CONTAINER_CLASSNAME_SIZE_MAP: Record<string, string> = {
|
||||
xs: 'w-4 h-4 rounded-[5px] shadow-xs',
|
||||
sm: 'w-5 h-5 rounded-md shadow-xs',
|
||||
md: 'w-6 h-6 rounded-lg shadow-md',
|
||||
}
|
||||
|
||||
const DEFAULT_ICON_MAP: Record<BlockEnum, React.ComponentType<{ className: string }>> = {
|
||||
[BlockEnum.Start]: Home,
|
||||
[BlockEnum.LLM]: Llm,
|
||||
[BlockEnum.Code]: Code,
|
||||
[BlockEnum.End]: End,
|
||||
[BlockEnum.IfElse]: IfElse,
|
||||
[BlockEnum.HttpRequest]: Http,
|
||||
[BlockEnum.Answer]: Answer,
|
||||
[BlockEnum.KnowledgeRetrieval]: KnowledgeRetrieval,
|
||||
[BlockEnum.QuestionClassifier]: QuestionClassifier,
|
||||
[BlockEnum.TemplateTransform]: TemplatingTransform,
|
||||
[BlockEnum.VariableAssigner]: VariableX,
|
||||
[BlockEnum.VariableAggregator]: VariableX,
|
||||
[BlockEnum.Assigner]: Assigner,
|
||||
[BlockEnum.Tool]: VariableX,
|
||||
[BlockEnum.IterationStart]: VariableX,
|
||||
[BlockEnum.Iteration]: Iteration,
|
||||
[BlockEnum.LoopStart]: VariableX,
|
||||
[BlockEnum.Loop]: Loop,
|
||||
[BlockEnum.LoopEnd]: LoopEnd,
|
||||
[BlockEnum.ParameterExtractor]: ParameterExtractor,
|
||||
[BlockEnum.DocExtractor]: DocsExtractor,
|
||||
[BlockEnum.ListFilter]: ListFilter,
|
||||
[BlockEnum.Agent]: Agent,
|
||||
[BlockEnum.KnowledgeBase]: KnowledgeBase,
|
||||
[BlockEnum.DataSource]: Datasource,
|
||||
[BlockEnum.DataSourceEmpty]: () => null,
|
||||
[BlockEnum.TriggerSchedule]: Schedule,
|
||||
[BlockEnum.TriggerWebhook]: WebhookLine,
|
||||
[BlockEnum.TriggerPlugin]: VariableX,
|
||||
}
|
||||
|
||||
const getIcon = (type: BlockEnum, className: string) => {
|
||||
const DefaultIcon = DEFAULT_ICON_MAP[type]
|
||||
if (!DefaultIcon)
|
||||
return null
|
||||
|
||||
return <DefaultIcon className={className} />
|
||||
}
|
||||
const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
|
||||
[BlockEnum.Start]: 'bg-util-colors-blue-brand-blue-brand-500',
|
||||
[BlockEnum.LLM]: 'bg-util-colors-indigo-indigo-500',
|
||||
[BlockEnum.Code]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.End]: 'bg-util-colors-warning-warning-500',
|
||||
[BlockEnum.IfElse]: 'bg-util-colors-cyan-cyan-500',
|
||||
[BlockEnum.Iteration]: 'bg-util-colors-cyan-cyan-500',
|
||||
[BlockEnum.Loop]: 'bg-util-colors-cyan-cyan-500',
|
||||
[BlockEnum.LoopEnd]: 'bg-util-colors-warning-warning-500',
|
||||
[BlockEnum.HttpRequest]: 'bg-util-colors-violet-violet-500',
|
||||
[BlockEnum.Answer]: 'bg-util-colors-warning-warning-500',
|
||||
[BlockEnum.KnowledgeRetrieval]: 'bg-util-colors-green-green-500',
|
||||
[BlockEnum.QuestionClassifier]: 'bg-util-colors-green-green-500',
|
||||
[BlockEnum.TemplateTransform]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.VariableAssigner]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.VariableAggregator]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.Tool]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.Assigner]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.ParameterExtractor]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.DocExtractor]: 'bg-util-colors-green-green-500',
|
||||
[BlockEnum.ListFilter]: 'bg-util-colors-cyan-cyan-500',
|
||||
[BlockEnum.Agent]: 'bg-util-colors-indigo-indigo-500',
|
||||
[BlockEnum.KnowledgeBase]: 'bg-util-colors-warning-warning-500',
|
||||
[BlockEnum.DataSource]: 'bg-components-icon-bg-midnight-solid',
|
||||
[BlockEnum.TriggerSchedule]: 'bg-util-colors-violet-violet-500',
|
||||
[BlockEnum.TriggerWebhook]: 'bg-util-colors-blue-blue-500',
|
||||
[BlockEnum.TriggerPlugin]: 'bg-util-colors-blue-blue-500',
|
||||
}
|
||||
const BlockIcon: FC<BlockIconProps> = ({
|
||||
type,
|
||||
size = 'sm',
|
||||
className,
|
||||
toolIcon,
|
||||
}) => {
|
||||
const isToolOrDataSourceOrTriggerPlugin = type === BlockEnum.Tool || type === BlockEnum.DataSource || type === BlockEnum.TriggerPlugin
|
||||
const showDefaultIcon = !isToolOrDataSourceOrTriggerPlugin || !toolIcon
|
||||
|
||||
return (
|
||||
<div className={
|
||||
cn(
|
||||
'flex items-center justify-center border-[0.5px] border-white/2 text-white',
|
||||
ICON_CONTAINER_CLASSNAME_SIZE_MAP[size],
|
||||
showDefaultIcon && ICON_CONTAINER_BG_COLOR_MAP[type],
|
||||
toolIcon && '!shadow-none',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{
|
||||
showDefaultIcon && (
|
||||
getIcon(type,
|
||||
(type === BlockEnum.TriggerSchedule || type === BlockEnum.TriggerWebhook)
|
||||
? (size === 'xs' ? 'w-4 h-4' : 'w-4.5 h-4.5')
|
||||
: (size === 'xs' ? 'w-3 h-3' : 'w-3.5 h-3.5'),
|
||||
)
|
||||
)
|
||||
}
|
||||
{
|
||||
!showDefaultIcon && (
|
||||
<>
|
||||
{
|
||||
typeof toolIcon === 'string'
|
||||
? (
|
||||
<div
|
||||
className='h-full w-full shrink-0 rounded-md bg-cover bg-center'
|
||||
style={{
|
||||
backgroundImage: `url(${toolIcon})`,
|
||||
}}
|
||||
></div>
|
||||
)
|
||||
: (
|
||||
<AppIcon
|
||||
className='!h-full !w-full shrink-0'
|
||||
size='tiny'
|
||||
icon={toolIcon?.content}
|
||||
background={toolIcon?.background}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const VarBlockIcon: FC<BlockIconProps> = ({
|
||||
type,
|
||||
className,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
{getIcon(type, `w-3 h-3 ${className}`)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(BlockIcon)
|
||||
@@ -0,0 +1,223 @@
|
||||
'use client'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type {
|
||||
RefObject,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { BlockEnum, OnSelectBlock } from '../types'
|
||||
import type { TriggerDefaultValue, TriggerWithProvider } from './types'
|
||||
import StartBlocks from './start-blocks'
|
||||
import TriggerPluginList from './trigger-plugin/list'
|
||||
import { ENTRY_NODE_TYPES } from './constants'
|
||||
import cn from '@/utils/classnames'
|
||||
import Link from 'next/link'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { BlockEnum as BlockEnumValue } from '../types'
|
||||
import FeaturedTriggers from './featured-triggers'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useAllTriggerPlugins, useInvalidateAllTriggerPlugins } from '@/service/use-triggers'
|
||||
import { useFeaturedTriggersRecommendations } from '@/service/use-plugins'
|
||||
import { PluginCategoryEnum } from '../../plugins/types'
|
||||
import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
|
||||
import PluginList, { type ListRef } from './market-place-plugin/list'
|
||||
|
||||
const marketplaceFooterClassName = 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg'
|
||||
|
||||
type AllStartBlocksProps = {
|
||||
className?: string
|
||||
searchText: string
|
||||
onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void
|
||||
availableBlocksTypes?: BlockEnum[]
|
||||
tags?: string[]
|
||||
allowUserInputSelection?: boolean // Allow user input option even when trigger node already exists (e.g. when no Start node yet or changing node type).
|
||||
}
|
||||
|
||||
const AllStartBlocks = ({
|
||||
className,
|
||||
searchText,
|
||||
onSelect,
|
||||
availableBlocksTypes,
|
||||
tags = [],
|
||||
allowUserInputSelection = false,
|
||||
}: AllStartBlocksProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [hasStartBlocksContent, setHasStartBlocksContent] = useState(false)
|
||||
const [hasPluginContent, setHasPluginContent] = useState(false)
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const pluginRef = useRef<ListRef>(null)
|
||||
const wrapElemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const entryNodeTypes = availableBlocksTypes?.length
|
||||
? availableBlocksTypes
|
||||
: ENTRY_NODE_TYPES
|
||||
const enableTriggerPlugin = entryNodeTypes.includes(BlockEnumValue.TriggerPlugin)
|
||||
const { data: triggerProviders = [] } = useAllTriggerPlugins(enableTriggerPlugin)
|
||||
const providerMap = useMemo(() => {
|
||||
const map = new Map<string, TriggerWithProvider>()
|
||||
triggerProviders.forEach((provider) => {
|
||||
const keys = [
|
||||
provider.plugin_id,
|
||||
provider.plugin_unique_identifier,
|
||||
provider.id,
|
||||
].filter(Boolean) as string[]
|
||||
keys.forEach((key) => {
|
||||
if (!map.has(key))
|
||||
map.set(key, provider)
|
||||
})
|
||||
})
|
||||
return map
|
||||
}, [triggerProviders])
|
||||
const invalidateTriggers = useInvalidateAllTriggerPlugins()
|
||||
const trimmedSearchText = searchText.trim()
|
||||
const hasSearchText = trimmedSearchText.length > 0
|
||||
const hasFilter = hasSearchText || tags.length > 0
|
||||
const {
|
||||
plugins: featuredPlugins = [],
|
||||
isLoading: featuredLoading,
|
||||
} = useFeaturedTriggersRecommendations(enableTriggerPlugin && enable_marketplace && !hasFilter)
|
||||
const {
|
||||
queryPluginsWithDebounced: fetchPlugins,
|
||||
plugins: marketplacePlugins = [],
|
||||
} = useMarketplacePlugins()
|
||||
|
||||
const shouldShowFeatured = enableTriggerPlugin
|
||||
&& enable_marketplace
|
||||
&& !hasFilter
|
||||
const shouldShowTriggerListTitle = hasStartBlocksContent || hasPluginContent
|
||||
const shouldShowMarketplaceFooter = enable_marketplace && !hasFilter
|
||||
|
||||
const handleStartBlocksContentChange = useCallback((hasContent: boolean) => {
|
||||
setHasStartBlocksContent(hasContent)
|
||||
}, [])
|
||||
|
||||
const handlePluginContentChange = useCallback((hasContent: boolean) => {
|
||||
setHasPluginContent(hasContent)
|
||||
}, [])
|
||||
|
||||
const hasMarketplaceContent = enableTriggerPlugin && enable_marketplace && marketplacePlugins.length > 0
|
||||
const hasAnyContent = hasStartBlocksContent || hasPluginContent || shouldShowFeatured || hasMarketplaceContent
|
||||
const shouldShowEmptyState = hasFilter && !hasAnyContent
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableTriggerPlugin && hasPluginContent)
|
||||
setHasPluginContent(false)
|
||||
}, [enableTriggerPlugin, hasPluginContent])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enableTriggerPlugin || !enable_marketplace) return
|
||||
if (hasFilter) {
|
||||
fetchPlugins({
|
||||
query: searchText,
|
||||
tags,
|
||||
category: PluginCategoryEnum.trigger,
|
||||
})
|
||||
}
|
||||
}, [enableTriggerPlugin, enable_marketplace, hasFilter, fetchPlugins, searchText, tags])
|
||||
|
||||
return (
|
||||
<div className={cn('min-w-[400px] max-w-[500px]', className)}>
|
||||
<div className='flex max-h-[640px] flex-col'>
|
||||
<div
|
||||
ref={wrapElemRef}
|
||||
className='flex-1 overflow-y-auto'
|
||||
onScroll={() => pluginRef.current?.handleScroll()}
|
||||
>
|
||||
<div className={cn(shouldShowEmptyState && 'hidden')}>
|
||||
{shouldShowFeatured && (
|
||||
<>
|
||||
<FeaturedTriggers
|
||||
plugins={featuredPlugins}
|
||||
providerMap={providerMap}
|
||||
onSelect={onSelect}
|
||||
isLoading={featuredLoading}
|
||||
onInstallSuccess={async () => {
|
||||
invalidateTriggers()
|
||||
}}
|
||||
/>
|
||||
<div className='px-3'>
|
||||
<Divider className='!h-px' />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{shouldShowTriggerListTitle && (
|
||||
<div className='px-3 pb-1 pt-2'>
|
||||
<span className='system-xs-medium text-text-primary'>{t('workflow.tabs.allTriggers')}</span>
|
||||
</div>
|
||||
)}
|
||||
<StartBlocks
|
||||
searchText={trimmedSearchText}
|
||||
onSelect={onSelect as OnSelectBlock}
|
||||
availableBlocksTypes={entryNodeTypes as unknown as BlockEnum[]}
|
||||
hideUserInput={!allowUserInputSelection}
|
||||
onContentStateChange={handleStartBlocksContentChange}
|
||||
/>
|
||||
|
||||
{enableTriggerPlugin && (
|
||||
<TriggerPluginList
|
||||
onSelect={onSelect}
|
||||
searchText={trimmedSearchText}
|
||||
onContentStateChange={handlePluginContentChange}
|
||||
tags={tags}
|
||||
/>
|
||||
)}
|
||||
{enableTriggerPlugin && enable_marketplace && (
|
||||
<PluginList
|
||||
ref={pluginRef}
|
||||
wrapElemRef={wrapElemRef as RefObject<HTMLElement>}
|
||||
list={marketplacePlugins}
|
||||
searchText={trimmedSearchText}
|
||||
tags={tags}
|
||||
hideFindMoreFooter
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shouldShowEmptyState && (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3 py-12 text-center'>
|
||||
<SearchMenu className='h-8 w-8 text-text-quaternary' />
|
||||
<div className='text-sm font-medium text-text-secondary'>
|
||||
{t('workflow.tabs.noPluginsFound')}
|
||||
</div>
|
||||
<Link
|
||||
href='https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml'
|
||||
target='_blank'
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
variant='secondary-accent'
|
||||
className='h-6 cursor-pointer px-3 text-xs'
|
||||
>
|
||||
{t('workflow.tabs.requestToCommunity')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shouldShowMarketplaceFooter && !shouldShowEmptyState && (
|
||||
// Footer - Same as Tools tab marketplace footer
|
||||
<Link
|
||||
className={marketplaceFooterClassName}
|
||||
href={getMarketplaceUrl('')}
|
||||
target='_blank'
|
||||
>
|
||||
<span>{t('plugin.findMoreInMarketplace')}</span>
|
||||
<RiArrowRightUpLine className='ml-0.5 h-3 w-3' />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AllStartBlocks
|
||||
330
dify/web/app/components/workflow/block-selector/all-tools.tsx
Normal file
330
dify/web/app/components/workflow/block-selector/all-tools.tsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import type {
|
||||
Dispatch,
|
||||
RefObject,
|
||||
SetStateAction,
|
||||
} from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
BlockEnum,
|
||||
ToolWithProvider,
|
||||
} from '../types'
|
||||
import type { ToolDefaultValue, ToolValue } from './types'
|
||||
import { ToolTypeEnum } from './types'
|
||||
import Tools from './tools'
|
||||
import { useToolTabs } from './hooks'
|
||||
import ViewTypeSelect, { ViewType } from './view-type-select'
|
||||
import cn from '@/utils/classnames'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { SearchMenu } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import type { ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
|
||||
import PluginList, { type ListProps } from '@/app/components/workflow/block-selector/market-place-plugin/list'
|
||||
import type { Plugin } from '../../plugins/types'
|
||||
import { PluginCategoryEnum } from '../../plugins/types'
|
||||
import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import RAGToolRecommendations from './rag-tool-recommendations'
|
||||
import FeaturedTools from './featured-tools'
|
||||
import Link from 'next/link'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import type { OnSelectBlock } from '@/app/components/workflow/types'
|
||||
|
||||
const marketplaceFooterClassName = 'system-sm-medium z-10 flex h-8 flex-none cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg'
|
||||
|
||||
type AllToolsProps = {
|
||||
className?: string
|
||||
toolContentClassName?: string
|
||||
searchText: string
|
||||
tags: ListProps['tags']
|
||||
buildInTools: ToolWithProvider[]
|
||||
customTools: ToolWithProvider[]
|
||||
workflowTools: ToolWithProvider[]
|
||||
mcpTools: ToolWithProvider[]
|
||||
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
|
||||
canNotSelectMultiple?: boolean
|
||||
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
onTagsChange?: Dispatch<SetStateAction<string[]>>
|
||||
isInRAGPipeline?: boolean
|
||||
featuredPlugins?: Plugin[]
|
||||
featuredLoading?: boolean
|
||||
showFeatured?: boolean
|
||||
onFeaturedInstallSuccess?: () => Promise<void> | void
|
||||
}
|
||||
|
||||
const DEFAULT_TAGS: AllToolsProps['tags'] = []
|
||||
|
||||
const AllTools = ({
|
||||
className,
|
||||
toolContentClassName,
|
||||
searchText,
|
||||
tags = DEFAULT_TAGS,
|
||||
onSelect,
|
||||
canNotSelectMultiple,
|
||||
onSelectMultiple,
|
||||
buildInTools,
|
||||
workflowTools,
|
||||
customTools,
|
||||
mcpTools = [],
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
onTagsChange,
|
||||
isInRAGPipeline = false,
|
||||
featuredPlugins = [],
|
||||
featuredLoading = false,
|
||||
showFeatured = false,
|
||||
onFeaturedInstallSuccess,
|
||||
}: AllToolsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const tabs = useToolTabs()
|
||||
const [activeTab, setActiveTab] = useState(ToolTypeEnum.All)
|
||||
const [activeView, setActiveView] = useState<ViewType>(ViewType.flat)
|
||||
const trimmedSearchText = searchText.trim()
|
||||
const hasSearchText = trimmedSearchText.length > 0
|
||||
const hasTags = tags.length > 0
|
||||
const hasFilter = hasSearchText || hasTags
|
||||
const isMatchingKeywords = (text: string, keywords: string) => {
|
||||
return text.toLowerCase().includes(keywords.toLowerCase())
|
||||
}
|
||||
const allProviders = useMemo(() => [...buildInTools, ...customTools, ...workflowTools, ...mcpTools], [buildInTools, customTools, workflowTools, mcpTools])
|
||||
const providerMap = useMemo(() => {
|
||||
const map = new Map<string, ToolWithProvider>()
|
||||
allProviders.forEach((provider) => {
|
||||
const key = provider.plugin_id || provider.id
|
||||
if (key)
|
||||
map.set(key, provider)
|
||||
})
|
||||
return map
|
||||
}, [allProviders])
|
||||
const tools = useMemo(() => {
|
||||
let mergedTools: ToolWithProvider[] = []
|
||||
if (activeTab === ToolTypeEnum.All)
|
||||
mergedTools = [...buildInTools, ...customTools, ...workflowTools, ...mcpTools]
|
||||
if (activeTab === ToolTypeEnum.BuiltIn)
|
||||
mergedTools = buildInTools
|
||||
if (activeTab === ToolTypeEnum.Custom)
|
||||
mergedTools = customTools
|
||||
if (activeTab === ToolTypeEnum.Workflow)
|
||||
mergedTools = workflowTools
|
||||
if (activeTab === ToolTypeEnum.MCP)
|
||||
mergedTools = mcpTools
|
||||
|
||||
const normalizedSearch = trimmedSearchText.toLowerCase()
|
||||
const getLocalizedText = (text?: Record<string, string> | null) => {
|
||||
if (!text)
|
||||
return ''
|
||||
|
||||
if (text[language])
|
||||
return text[language]
|
||||
|
||||
if (text['en-US'])
|
||||
return text['en-US']
|
||||
|
||||
const firstValue = Object.values(text).find(Boolean)
|
||||
return firstValue || ''
|
||||
}
|
||||
|
||||
if (!hasFilter || !normalizedSearch)
|
||||
return mergedTools.filter(toolWithProvider => toolWithProvider.tools.length > 0)
|
||||
|
||||
return mergedTools.reduce<ToolWithProvider[]>((acc, toolWithProvider) => {
|
||||
const providerLabel = getLocalizedText(toolWithProvider.label)
|
||||
const providerMatches = [
|
||||
toolWithProvider.name,
|
||||
providerLabel,
|
||||
].some(text => isMatchingKeywords(text || '', normalizedSearch))
|
||||
|
||||
if (providerMatches) {
|
||||
if (toolWithProvider.tools.length > 0)
|
||||
acc.push(toolWithProvider)
|
||||
return acc
|
||||
}
|
||||
|
||||
const matchedTools = toolWithProvider.tools.filter((tool) => {
|
||||
const toolLabel = getLocalizedText(tool.label)
|
||||
return [
|
||||
tool.name,
|
||||
toolLabel,
|
||||
].some(text => isMatchingKeywords(text || '', normalizedSearch))
|
||||
})
|
||||
|
||||
if (matchedTools.length > 0) {
|
||||
acc.push({
|
||||
...toolWithProvider,
|
||||
tools: matchedTools,
|
||||
})
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
}, [activeTab, buildInTools, customTools, workflowTools, mcpTools, trimmedSearchText, hasFilter, language])
|
||||
|
||||
const {
|
||||
queryPluginsWithDebounced: fetchPlugins,
|
||||
plugins: notInstalledPlugins = [],
|
||||
} = useMarketplacePlugins()
|
||||
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enable_marketplace) return
|
||||
if (hasFilter) {
|
||||
fetchPlugins({
|
||||
query: searchText,
|
||||
tags,
|
||||
category: PluginCategoryEnum.tool,
|
||||
})
|
||||
}
|
||||
}, [searchText, tags, enable_marketplace, hasFilter, fetchPlugins])
|
||||
|
||||
const pluginRef = useRef<ListRef>(null)
|
||||
const wrapElemRef = useRef<HTMLDivElement>(null)
|
||||
const isSupportGroupView = [ToolTypeEnum.All, ToolTypeEnum.BuiltIn].includes(activeTab)
|
||||
|
||||
const isShowRAGRecommendations = isInRAGPipeline && activeTab === ToolTypeEnum.All && !hasFilter
|
||||
const hasToolsListContent = tools.length > 0 || isShowRAGRecommendations
|
||||
const hasPluginContent = enable_marketplace && notInstalledPlugins.length > 0
|
||||
const shouldShowEmptyState = hasFilter && !hasToolsListContent && !hasPluginContent
|
||||
const shouldShowFeatured = showFeatured
|
||||
&& enable_marketplace
|
||||
&& !isInRAGPipeline
|
||||
&& activeTab === ToolTypeEnum.All
|
||||
&& !hasFilter
|
||||
const shouldShowMarketplaceFooter = enable_marketplace && !hasFilter
|
||||
|
||||
const handleRAGSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
if (!pluginDefaultValue)
|
||||
return
|
||||
onSelect(type, pluginDefaultValue as ToolDefaultValue)
|
||||
}, [onSelect])
|
||||
|
||||
return (
|
||||
<div className={cn('min-w-[400px] max-w-[500px]', className)}>
|
||||
<div className='flex items-center justify-between border-b border-divider-subtle px-3'>
|
||||
<div className='flex h-8 items-center space-x-1'>
|
||||
{
|
||||
tabs.map(tab => (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-6 cursor-pointer items-center rounded-md px-2 hover:bg-state-base-hover',
|
||||
'text-xs font-medium text-text-secondary',
|
||||
activeTab === tab.key && 'bg-state-base-hover-alt',
|
||||
)}
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
{isSupportGroupView && (
|
||||
<ViewTypeSelect viewType={activeView} onChange={setActiveView} />
|
||||
)}
|
||||
</div>
|
||||
<div className='flex max-h-[464px] flex-col'>
|
||||
<div
|
||||
ref={wrapElemRef}
|
||||
className='flex-1 overflow-y-auto'
|
||||
onScroll={() => pluginRef.current?.handleScroll()}
|
||||
>
|
||||
<div className={cn(shouldShowEmptyState && 'hidden')}>
|
||||
{isShowRAGRecommendations && onTagsChange && (
|
||||
<RAGToolRecommendations
|
||||
viewType={isSupportGroupView ? activeView : ViewType.flat}
|
||||
onSelect={handleRAGSelect}
|
||||
onTagsChange={onTagsChange}
|
||||
/>
|
||||
)}
|
||||
{shouldShowFeatured && (
|
||||
<>
|
||||
<FeaturedTools
|
||||
plugins={featuredPlugins}
|
||||
providerMap={providerMap}
|
||||
onSelect={onSelect}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
isLoading={featuredLoading}
|
||||
onInstallSuccess={async () => {
|
||||
await onFeaturedInstallSuccess?.()
|
||||
}}
|
||||
/>
|
||||
<div className='px-3'>
|
||||
<Divider className='!h-px' />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{hasToolsListContent && (
|
||||
<>
|
||||
<div className='px-3 pb-1 pt-2'>
|
||||
<span className='system-xs-medium text-text-primary'>{t('tools.allTools')}</span>
|
||||
</div>
|
||||
<Tools
|
||||
className={toolContentClassName}
|
||||
tools={tools}
|
||||
onSelect={onSelect}
|
||||
canNotSelectMultiple={canNotSelectMultiple}
|
||||
onSelectMultiple={onSelectMultiple}
|
||||
toolType={activeTab}
|
||||
viewType={isSupportGroupView ? activeView : ViewType.flat}
|
||||
hasSearchText={hasSearchText}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{enable_marketplace && (
|
||||
<PluginList
|
||||
ref={pluginRef}
|
||||
wrapElemRef={wrapElemRef as RefObject<HTMLElement>}
|
||||
list={notInstalledPlugins}
|
||||
searchText={searchText}
|
||||
toolContentClassName={toolContentClassName}
|
||||
tags={tags}
|
||||
hideFindMoreFooter
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shouldShowEmptyState && (
|
||||
<div className='flex h-full flex-col items-center justify-center gap-3 py-12 text-center'>
|
||||
<SearchMenu className='h-8 w-8 text-text-quaternary' />
|
||||
<div className='text-sm font-medium text-text-secondary'>
|
||||
{t('workflow.tabs.noPluginsFound')}
|
||||
</div>
|
||||
<Link
|
||||
href='https://github.com/langgenius/dify-plugins/issues/new?template=plugin_request.yaml'
|
||||
target='_blank'
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
variant='secondary-accent'
|
||||
className='h-6 cursor-pointer px-3 text-xs'
|
||||
>
|
||||
{t('workflow.tabs.requestToCommunity')}
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{shouldShowMarketplaceFooter && (
|
||||
<Link
|
||||
className={marketplaceFooterClassName}
|
||||
href={getMarketplaceUrl('')}
|
||||
target='_blank'
|
||||
>
|
||||
<span>{t('plugin.findMoreInMarketplace')}</span>
|
||||
<RiArrowRightUpLine className='ml-0.5 h-3 w-3' />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AllTools
|
||||
150
dify/web/app/components/workflow/block-selector/blocks.tsx
Normal file
150
dify/web/app/components/workflow/block-selector/blocks.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { groupBy } from 'lodash-es'
|
||||
import BlockIcon from '../block-icon'
|
||||
import { BlockEnum } from '../types'
|
||||
import type { NodeDefault } from '../types'
|
||||
import { BLOCK_CLASSIFICATIONS } from './constants'
|
||||
import { useBlocks } from './hooks'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
|
||||
type BlocksProps = {
|
||||
searchText: string
|
||||
onSelect: (type: BlockEnum) => void
|
||||
availableBlocksTypes?: BlockEnum[]
|
||||
blocks?: NodeDefault[]
|
||||
}
|
||||
const Blocks = ({
|
||||
searchText,
|
||||
onSelect,
|
||||
availableBlocksTypes = [],
|
||||
blocks: blocksFromProps,
|
||||
}: BlocksProps) => {
|
||||
const { t } = useTranslation()
|
||||
const store = useStoreApi()
|
||||
const blocksFromHooks = useBlocks()
|
||||
|
||||
// Use external blocks if provided, otherwise fallback to hook-based blocks
|
||||
const blocks = blocksFromProps || blocksFromHooks.map(block => ({
|
||||
metaData: {
|
||||
classification: block.classification,
|
||||
sort: 0, // Default sort order
|
||||
type: block.type,
|
||||
title: block.title,
|
||||
author: 'Dify',
|
||||
description: block.description,
|
||||
},
|
||||
defaultValue: {},
|
||||
checkValid: () => ({ isValid: true }),
|
||||
}) as NodeDefault)
|
||||
|
||||
const groups = useMemo(() => {
|
||||
return BLOCK_CLASSIFICATIONS.reduce((acc, classification) => {
|
||||
const grouped = groupBy(blocks, 'metaData.classification')
|
||||
const list = (grouped[classification] || []).filter((block) => {
|
||||
// Filter out trigger types from Blocks tab
|
||||
if (block.metaData.type === BlockEnum.TriggerWebhook
|
||||
|| block.metaData.type === BlockEnum.TriggerSchedule
|
||||
|| block.metaData.type === BlockEnum.TriggerPlugin)
|
||||
return false
|
||||
|
||||
return block.metaData.title.toLowerCase().includes(searchText.toLowerCase()) && availableBlocksTypes.includes(block.metaData.type)
|
||||
})
|
||||
|
||||
return {
|
||||
...acc,
|
||||
[classification]: list,
|
||||
}
|
||||
}, {} as Record<string, typeof blocks>)
|
||||
}, [blocks, searchText, availableBlocksTypes])
|
||||
const isEmpty = Object.values(groups).every(list => !list.length)
|
||||
|
||||
const renderGroup = useCallback((classification: string) => {
|
||||
const list = groups[classification].sort((a, b) => (a.metaData.sort || 0) - (b.metaData.sort || 0))
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const hasKnowledgeBaseNode = nodes.some(node => node.data.type === BlockEnum.KnowledgeBase)
|
||||
const filteredList = list.filter((block) => {
|
||||
if (hasKnowledgeBaseNode)
|
||||
return block.metaData.type !== BlockEnum.KnowledgeBase
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
key={classification}
|
||||
className='mb-1 last-of-type:mb-0'
|
||||
>
|
||||
{
|
||||
classification !== '-' && !!filteredList.length && (
|
||||
<div className='flex h-[22px] items-start px-3 text-xs font-medium text-text-tertiary'>
|
||||
{t(`workflow.tabs.${classification}`)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
filteredList.map(block => (
|
||||
<Tooltip
|
||||
key={block.metaData.type}
|
||||
position='right'
|
||||
popupClassName='w-[200px] rounded-xl'
|
||||
needsDelay={false}
|
||||
popupContent={(
|
||||
<div>
|
||||
<BlockIcon
|
||||
size='md'
|
||||
className='mb-2'
|
||||
type={block.metaData.type}
|
||||
/>
|
||||
<div className='system-md-medium mb-1 text-text-primary'>{block.metaData.title}</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>{block.metaData.description}</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key={block.metaData.type}
|
||||
className='flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover'
|
||||
onClick={() => onSelect(block.metaData.type)}
|
||||
>
|
||||
<BlockIcon
|
||||
className='mr-2 shrink-0'
|
||||
type={block.metaData.type}
|
||||
/>
|
||||
<div className='grow text-sm text-text-secondary'>{block.metaData.title}</div>
|
||||
{
|
||||
block.metaData.type === BlockEnum.LoopEnd && (
|
||||
<Badge
|
||||
text={t('workflow.nodes.loop.loopNode')}
|
||||
className='ml-2 shrink-0'
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</Tooltip>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}, [groups, onSelect, t, store])
|
||||
|
||||
return (
|
||||
<div className='max-h-[480px] min-w-[400px] max-w-[500px] overflow-y-auto p-1'>
|
||||
{
|
||||
isEmpty && (
|
||||
<div className='flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary'>{t('workflow.tabs.noResult')}</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isEmpty && BLOCK_CLASSIFICATIONS.map(renderGroup)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Blocks)
|
||||
155
dify/web/app/components/workflow/block-selector/constants.tsx
Normal file
155
dify/web/app/components/workflow/block-selector/constants.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import type { Block } from '../types'
|
||||
import { BlockEnum } from '../types'
|
||||
import { BlockClassificationEnum } from './types'
|
||||
|
||||
export const BLOCK_CLASSIFICATIONS: string[] = [
|
||||
BlockClassificationEnum.Default,
|
||||
BlockClassificationEnum.QuestionUnderstand,
|
||||
BlockClassificationEnum.Logic,
|
||||
BlockClassificationEnum.Transform,
|
||||
BlockClassificationEnum.Utilities,
|
||||
]
|
||||
|
||||
export const DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE = [
|
||||
'txt',
|
||||
'markdown',
|
||||
'mdx',
|
||||
'pdf',
|
||||
'html',
|
||||
'xlsx',
|
||||
'xls',
|
||||
'vtt',
|
||||
'properties',
|
||||
'doc',
|
||||
'docx',
|
||||
'csv',
|
||||
'eml',
|
||||
'msg',
|
||||
'pptx',
|
||||
'xml',
|
||||
'epub',
|
||||
'ppt',
|
||||
'md',
|
||||
]
|
||||
|
||||
export const START_BLOCKS: Block[] = [
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
type: BlockEnum.Start,
|
||||
title: 'User Input',
|
||||
description: 'Traditional start node for user input',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
type: BlockEnum.TriggerSchedule,
|
||||
title: 'Schedule Trigger',
|
||||
description: 'Time-based workflow trigger',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
type: BlockEnum.TriggerWebhook,
|
||||
title: 'Webhook Trigger',
|
||||
description: 'HTTP callback trigger',
|
||||
},
|
||||
]
|
||||
|
||||
export const ENTRY_NODE_TYPES = [
|
||||
BlockEnum.Start,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
] as const
|
||||
|
||||
export const BLOCKS: Block[] = [
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
type: BlockEnum.LLM,
|
||||
title: 'LLM',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
type: BlockEnum.KnowledgeRetrieval,
|
||||
title: 'Knowledge Retrieval',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
type: BlockEnum.End,
|
||||
title: 'End',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
type: BlockEnum.Answer,
|
||||
title: 'Direct Answer',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.QuestionUnderstand,
|
||||
type: BlockEnum.QuestionClassifier,
|
||||
title: 'Question Classifier',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
type: BlockEnum.IfElse,
|
||||
title: 'IF/ELSE',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
type: BlockEnum.LoopEnd,
|
||||
title: 'Exit Loop',
|
||||
description: '',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
type: BlockEnum.Iteration,
|
||||
title: 'Iteration',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Logic,
|
||||
type: BlockEnum.Loop,
|
||||
title: 'Loop',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
type: BlockEnum.Code,
|
||||
title: 'Code',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
type: BlockEnum.TemplateTransform,
|
||||
title: 'Templating Transform',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
type: BlockEnum.VariableAggregator,
|
||||
title: 'Variable Aggregator',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
type: BlockEnum.DocExtractor,
|
||||
title: 'Doc Extractor',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
type: BlockEnum.Assigner,
|
||||
title: 'Variable Assigner',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Transform,
|
||||
type: BlockEnum.ParameterExtractor,
|
||||
title: 'Parameter Extractor',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Utilities,
|
||||
type: BlockEnum.HttpRequest,
|
||||
title: 'HTTP Request',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Utilities,
|
||||
type: BlockEnum.ListFilter,
|
||||
title: 'List Filter',
|
||||
},
|
||||
{
|
||||
classification: BlockClassificationEnum.Default,
|
||||
type: BlockEnum.Agent,
|
||||
title: 'Agent',
|
||||
},
|
||||
]
|
||||
126
dify/web/app/components/workflow/block-selector/data-sources.tsx
Normal file
126
dify/web/app/components/workflow/block-selector/data-sources.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { BlockEnum } from '../types'
|
||||
import type {
|
||||
OnSelectBlock,
|
||||
ToolWithProvider,
|
||||
} from '../types'
|
||||
import type { DataSourceDefaultValue, ToolDefaultValue } from './types'
|
||||
import Tools from './tools'
|
||||
import { ViewType } from './view-type-select'
|
||||
import cn from '@/utils/classnames'
|
||||
import PluginList, { type ListRef } from '@/app/components/workflow/block-selector/market-place-plugin/list'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE } from './constants'
|
||||
import { useMarketplacePlugins } from '../../plugins/marketplace/hooks'
|
||||
import { PluginCategoryEnum } from '../../plugins/types'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
|
||||
type AllToolsProps = {
|
||||
className?: string
|
||||
toolContentClassName?: string
|
||||
searchText: string
|
||||
onSelect: OnSelectBlock
|
||||
dataSources: ToolWithProvider[]
|
||||
}
|
||||
|
||||
const DataSources = ({
|
||||
className,
|
||||
toolContentClassName,
|
||||
searchText,
|
||||
onSelect,
|
||||
dataSources,
|
||||
}: AllToolsProps) => {
|
||||
const language = useGetLanguage()
|
||||
const pluginRef = useRef<ListRef>(null)
|
||||
const wrapElemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const isMatchingKeywords = (text: string, keywords: string) => {
|
||||
return text.toLowerCase().includes(keywords.toLowerCase())
|
||||
}
|
||||
|
||||
const filteredDatasources = useMemo(() => {
|
||||
const hasFilter = searchText
|
||||
if (!hasFilter)
|
||||
return dataSources.filter(toolWithProvider => toolWithProvider.tools.length > 0)
|
||||
|
||||
return dataSources.filter((toolWithProvider) => {
|
||||
return isMatchingKeywords(toolWithProvider.name, searchText) || toolWithProvider.tools.some((tool) => {
|
||||
return tool.label[language].toLowerCase().includes(searchText.toLowerCase()) || tool.name.toLowerCase().includes(searchText.toLowerCase())
|
||||
})
|
||||
})
|
||||
}, [searchText, dataSources, language])
|
||||
|
||||
const handleSelect = useCallback((_: BlockEnum, toolDefaultValue: ToolDefaultValue) => {
|
||||
let defaultValue: DataSourceDefaultValue = {
|
||||
plugin_id: toolDefaultValue?.provider_id,
|
||||
provider_type: toolDefaultValue?.provider_type,
|
||||
provider_name: toolDefaultValue?.provider_name,
|
||||
datasource_name: toolDefaultValue?.tool_name,
|
||||
datasource_label: toolDefaultValue?.tool_label,
|
||||
title: toolDefaultValue?.title,
|
||||
plugin_unique_identifier: toolDefaultValue?.plugin_unique_identifier,
|
||||
}
|
||||
// Update defaultValue with fileExtensions if this is the local file data source
|
||||
if (toolDefaultValue?.provider_id === 'langgenius/file' && toolDefaultValue?.provider_name === 'file') {
|
||||
defaultValue = {
|
||||
...defaultValue,
|
||||
fileExtensions: DEFAULT_FILE_EXTENSIONS_IN_LOCAL_FILE_DATA_SOURCE,
|
||||
}
|
||||
}
|
||||
onSelect(BlockEnum.DataSource, toolDefaultValue && defaultValue)
|
||||
}, [onSelect])
|
||||
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const {
|
||||
queryPluginsWithDebounced: fetchPlugins,
|
||||
plugins: notInstalledPlugins = [],
|
||||
} = useMarketplacePlugins()
|
||||
|
||||
useEffect(() => {
|
||||
if (!enable_marketplace) return
|
||||
if (searchText) {
|
||||
fetchPlugins({
|
||||
query: searchText,
|
||||
category: PluginCategoryEnum.datasource,
|
||||
})
|
||||
}
|
||||
}, [searchText, enable_marketplace])
|
||||
|
||||
return (
|
||||
<div className={cn('w-[400px] min-w-0 max-w-full', className)}>
|
||||
<div
|
||||
ref={wrapElemRef}
|
||||
className='max-h-[464px] overflow-y-auto overflow-x-hidden'
|
||||
onScroll={pluginRef.current?.handleScroll}
|
||||
>
|
||||
<Tools
|
||||
className={toolContentClassName}
|
||||
tools={filteredDatasources}
|
||||
onSelect={handleSelect as OnSelectBlock}
|
||||
viewType={ViewType.flat}
|
||||
hasSearchText={!!searchText}
|
||||
canNotSelectMultiple
|
||||
/>
|
||||
{/* Plugins from marketplace */}
|
||||
{enable_marketplace && (
|
||||
<PluginList
|
||||
ref={pluginRef}
|
||||
wrapElemRef={wrapElemRef}
|
||||
list={notInstalledPlugins}
|
||||
tags={[]}
|
||||
searchText={searchText}
|
||||
toolContentClassName={toolContentClassName}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DataSources
|
||||
@@ -0,0 +1,333 @@
|
||||
'use client'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BlockEnum, type ToolWithProvider } from '../types'
|
||||
import type { ToolDefaultValue, ToolValue } from './types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import BlockIcon from '../block-icon'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { RiMoreLine } from '@remixicon/react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Link from 'next/link'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { ToolTypeEnum } from './types'
|
||||
import { ViewType } from './view-type-select'
|
||||
import Tools from './tools'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
|
||||
import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
|
||||
const MAX_RECOMMENDED_COUNT = 15
|
||||
const INITIAL_VISIBLE_COUNT = 5
|
||||
|
||||
type FeaturedToolsProps = {
|
||||
plugins: Plugin[]
|
||||
providerMap: Map<string, ToolWithProvider>
|
||||
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
isLoading?: boolean
|
||||
onInstallSuccess?: () => void
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'workflow_tools_featured_collapsed'
|
||||
|
||||
const FeaturedTools = ({
|
||||
plugins,
|
||||
providerMap,
|
||||
onSelect,
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
isLoading = false,
|
||||
onInstallSuccess,
|
||||
}: FeaturedToolsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined')
|
||||
return false
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
return stored === 'true'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (stored !== null)
|
||||
setIsCollapsed(stored === 'true')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
|
||||
}, [isCollapsed])
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(INITIAL_VISIBLE_COUNT)
|
||||
}, [plugins])
|
||||
|
||||
const limitedPlugins = useMemo(
|
||||
() => plugins.slice(0, MAX_RECOMMENDED_COUNT),
|
||||
[plugins],
|
||||
)
|
||||
|
||||
const {
|
||||
installedProviders,
|
||||
uninstalledPlugins,
|
||||
} = useMemo(() => {
|
||||
const installed: ToolWithProvider[] = []
|
||||
const uninstalled: Plugin[] = []
|
||||
const visitedProviderIds = new Set<string>()
|
||||
|
||||
limitedPlugins.forEach((plugin) => {
|
||||
const provider = providerMap.get(plugin.plugin_id)
|
||||
if (provider) {
|
||||
if (!visitedProviderIds.has(provider.id)) {
|
||||
installed.push(provider)
|
||||
visitedProviderIds.add(provider.id)
|
||||
}
|
||||
}
|
||||
else {
|
||||
uninstalled.push(plugin)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
installedProviders: installed,
|
||||
uninstalledPlugins: uninstalled,
|
||||
}
|
||||
}, [limitedPlugins, providerMap])
|
||||
|
||||
const totalQuota = Math.min(visibleCount, MAX_RECOMMENDED_COUNT)
|
||||
|
||||
const visibleInstalledProviders = useMemo(
|
||||
() => installedProviders.slice(0, totalQuota),
|
||||
[installedProviders, totalQuota],
|
||||
)
|
||||
|
||||
const remainingSlots = Math.max(totalQuota - visibleInstalledProviders.length, 0)
|
||||
|
||||
const visibleUninstalledPlugins = useMemo(
|
||||
() => (remainingSlots > 0 ? uninstalledPlugins.slice(0, remainingSlots) : []),
|
||||
[uninstalledPlugins, remainingSlots],
|
||||
)
|
||||
|
||||
const totalVisible = visibleInstalledProviders.length + visibleUninstalledPlugins.length
|
||||
const maxAvailable = Math.min(MAX_RECOMMENDED_COUNT, installedProviders.length + uninstalledPlugins.length)
|
||||
const hasMoreToShow = totalVisible < maxAvailable
|
||||
const canToggleVisibility = maxAvailable > INITIAL_VISIBLE_COUNT
|
||||
const isExpanded = canToggleVisibility && !hasMoreToShow
|
||||
const showEmptyState = !isLoading && totalVisible === 0
|
||||
|
||||
return (
|
||||
<div className='px-3 pb-3 pt-2'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex w-full items-center rounded-md px-0 py-1 text-left text-text-primary'
|
||||
onClick={() => setIsCollapsed(prev => !prev)}
|
||||
>
|
||||
<span className='system-xs-medium text-text-primary'>{t('workflow.tabs.featuredTools')}</span>
|
||||
<ArrowDownRoundFill className={`ml-0.5 h-4 w-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} />
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className='py-3'>
|
||||
<Loading type='app' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<p className='system-xs-regular py-2 text-text-tertiary'>
|
||||
<Link className='text-text-accent' href={getMarketplaceUrl('', { category: 'tool' })} target='_blank' rel='noopener noreferrer'>
|
||||
{t('workflow.tabs.noFeaturedPlugins')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!showEmptyState && !isLoading && (
|
||||
<>
|
||||
{visibleInstalledProviders.length > 0 && (
|
||||
<Tools
|
||||
className='p-0'
|
||||
tools={visibleInstalledProviders}
|
||||
onSelect={onSelect}
|
||||
canNotSelectMultiple
|
||||
toolType={ToolTypeEnum.All}
|
||||
viewType={ViewType.flat}
|
||||
hasSearchText={false}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
)}
|
||||
|
||||
{visibleUninstalledPlugins.length > 0 && (
|
||||
<div className='mt-1 flex flex-col gap-1'>
|
||||
{visibleUninstalledPlugins.map(plugin => (
|
||||
<FeaturedToolUninstalledItem
|
||||
key={plugin.plugin_id}
|
||||
plugin={plugin}
|
||||
language={language}
|
||||
onInstallSuccess={async () => {
|
||||
await onInstallSuccess?.()
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && totalVisible > 0 && canToggleVisibility && (
|
||||
<div
|
||||
className='group mt-1 flex cursor-pointer items-center gap-x-2 rounded-lg py-1 pl-3 pr-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary'
|
||||
onClick={() => {
|
||||
setVisibleCount((count) => {
|
||||
if (count >= maxAvailable)
|
||||
return INITIAL_VISIBLE_COUNT
|
||||
|
||||
return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center px-1 text-text-tertiary transition-colors group-hover:text-text-secondary'>
|
||||
<RiMoreLine className='size-4 group-hover:hidden' />
|
||||
{isExpanded ? (
|
||||
<ArrowUpDoubleLine className='hidden size-4 group-hover:block' />
|
||||
) : (
|
||||
<ArrowDownDoubleLine className='hidden size-4 group-hover:block' />
|
||||
)}
|
||||
</div>
|
||||
<div className='system-xs-regular'>
|
||||
{t(isExpanded ? 'workflow.tabs.showLessFeatured' : 'workflow.tabs.showMoreFeatured')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FeaturedToolUninstalledItemProps = {
|
||||
plugin: Plugin
|
||||
language: string
|
||||
onInstallSuccess?: () => Promise<void> | void
|
||||
t: (key: string, options?: Record<string, any>) => string
|
||||
}
|
||||
|
||||
function FeaturedToolUninstalledItem({
|
||||
plugin,
|
||||
language,
|
||||
onInstallSuccess,
|
||||
t,
|
||||
}: FeaturedToolUninstalledItemProps) {
|
||||
const label = plugin.label?.[language] || plugin.name
|
||||
const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief
|
||||
const installCountLabel = t('plugin.install', { num: formatNumber(plugin.install_count || 0) })
|
||||
const [actionOpen, setActionOpen] = useState(false)
|
||||
const [isActionHovered, setIsActionHovered] = useState(false)
|
||||
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionOpen)
|
||||
return
|
||||
|
||||
const handleScroll = () => {
|
||||
setActionOpen(false)
|
||||
setIsActionHovered(false)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
}
|
||||
}, [actionOpen])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
position='right'
|
||||
needsDelay={false}
|
||||
popupClassName='!p-0 !px-3 !py-2.5 !w-[224px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg'
|
||||
popupContent={(
|
||||
<div>
|
||||
<BlockIcon size='md' className='mb-2' type={BlockEnum.Tool} toolIcon={plugin.icon} />
|
||||
<div className='mb-1 text-sm leading-5 text-text-primary'>{label}</div>
|
||||
<div className='text-xs leading-[18px] text-text-secondary'>{description}</div>
|
||||
</div>
|
||||
)}
|
||||
disabled={!description || isActionHovered || actionOpen || isInstallModalOpen}
|
||||
>
|
||||
<div
|
||||
className='group flex h-8 w-full items-center rounded-lg pl-3 pr-1 hover:bg-state-base-hover'
|
||||
>
|
||||
<div className='flex h-full min-w-0 items-center'>
|
||||
<BlockIcon type={BlockEnum.Tool} toolIcon={plugin.icon} />
|
||||
<div className='ml-2 min-w-0'>
|
||||
<div className='system-sm-medium truncate text-text-secondary'>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='ml-auto flex h-full items-center gap-1 pl-1'>
|
||||
<span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span>
|
||||
<div
|
||||
className={`system-xs-medium flex h-full items-center gap-1 text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? 'flex' : 'hidden group-hover:flex'}`}
|
||||
onMouseEnter={() => setIsActionHovered(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!actionOpen)
|
||||
setIsActionHovered(false)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover'
|
||||
onClick={() => {
|
||||
setActionOpen(false)
|
||||
setIsInstallModalOpen(true)
|
||||
setIsActionHovered(true)
|
||||
}}
|
||||
>
|
||||
{t('plugin.installAction')}
|
||||
</button>
|
||||
<Action
|
||||
open={actionOpen}
|
||||
onOpenChange={(value) => {
|
||||
setActionOpen(value)
|
||||
setIsActionHovered(value)
|
||||
}}
|
||||
author={plugin.org}
|
||||
name={plugin.name}
|
||||
version={plugin.latest_version}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{isInstallModalOpen && (
|
||||
<InstallFromMarketplace
|
||||
uniqueIdentifier={plugin.latest_package_identifier}
|
||||
manifest={plugin}
|
||||
onSuccess={async () => {
|
||||
setIsInstallModalOpen(false)
|
||||
setIsActionHovered(false)
|
||||
await onInstallSuccess?.()
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsInstallModalOpen(false)
|
||||
setIsActionHovered(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeaturedTools
|
||||
@@ -0,0 +1,326 @@
|
||||
'use client'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BlockEnum } from '../types'
|
||||
import type { TriggerDefaultValue, TriggerWithProvider } from './types'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import BlockIcon from '../block-icon'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { RiMoreLine } from '@remixicon/react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Link from 'next/link'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import TriggerPluginItem from './trigger-plugin/item'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import Action from '@/app/components/workflow/block-selector/market-place-plugin/action'
|
||||
import { ArrowDownDoubleLine, ArrowDownRoundFill, ArrowUpDoubleLine } from '@/app/components/base/icons/src/vender/solid/arrows'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
|
||||
const MAX_RECOMMENDED_COUNT = 15
|
||||
const INITIAL_VISIBLE_COUNT = 5
|
||||
|
||||
type FeaturedTriggersProps = {
|
||||
plugins: Plugin[]
|
||||
providerMap: Map<string, TriggerWithProvider>
|
||||
onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void
|
||||
isLoading?: boolean
|
||||
onInstallSuccess?: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'workflow_triggers_featured_collapsed'
|
||||
|
||||
const FeaturedTriggers = ({
|
||||
plugins,
|
||||
providerMap,
|
||||
onSelect,
|
||||
isLoading = false,
|
||||
onInstallSuccess,
|
||||
}: FeaturedTriggersProps) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const [visibleCount, setVisibleCount] = useState(INITIAL_VISIBLE_COUNT)
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined')
|
||||
return false
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
return stored === 'true'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (stored !== null)
|
||||
setIsCollapsed(stored === 'true')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
|
||||
}, [isCollapsed])
|
||||
|
||||
useEffect(() => {
|
||||
setVisibleCount(INITIAL_VISIBLE_COUNT)
|
||||
}, [plugins])
|
||||
|
||||
const limitedPlugins = useMemo(
|
||||
() => plugins.slice(0, MAX_RECOMMENDED_COUNT),
|
||||
[plugins],
|
||||
)
|
||||
|
||||
const {
|
||||
installedProviders,
|
||||
uninstalledPlugins,
|
||||
} = useMemo(() => {
|
||||
const installed: TriggerWithProvider[] = []
|
||||
const uninstalled: Plugin[] = []
|
||||
const visitedProviderIds = new Set<string>()
|
||||
|
||||
limitedPlugins.forEach((plugin) => {
|
||||
const provider = providerMap.get(plugin.plugin_id) || providerMap.get(plugin.latest_package_identifier)
|
||||
if (provider) {
|
||||
if (!visitedProviderIds.has(provider.id)) {
|
||||
installed.push(provider)
|
||||
visitedProviderIds.add(provider.id)
|
||||
}
|
||||
}
|
||||
else {
|
||||
uninstalled.push(plugin)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
installedProviders: installed,
|
||||
uninstalledPlugins: uninstalled,
|
||||
}
|
||||
}, [limitedPlugins, providerMap])
|
||||
|
||||
const totalQuota = Math.min(visibleCount, MAX_RECOMMENDED_COUNT)
|
||||
|
||||
const visibleInstalledProviders = useMemo(
|
||||
() => installedProviders.slice(0, totalQuota),
|
||||
[installedProviders, totalQuota],
|
||||
)
|
||||
|
||||
const remainingSlots = Math.max(totalQuota - visibleInstalledProviders.length, 0)
|
||||
|
||||
const visibleUninstalledPlugins = useMemo(
|
||||
() => (remainingSlots > 0 ? uninstalledPlugins.slice(0, remainingSlots) : []),
|
||||
[uninstalledPlugins, remainingSlots],
|
||||
)
|
||||
|
||||
const totalVisible = visibleInstalledProviders.length + visibleUninstalledPlugins.length
|
||||
const maxAvailable = Math.min(MAX_RECOMMENDED_COUNT, installedProviders.length + uninstalledPlugins.length)
|
||||
const hasMoreToShow = totalVisible < maxAvailable
|
||||
const canToggleVisibility = maxAvailable > INITIAL_VISIBLE_COUNT
|
||||
const isExpanded = canToggleVisibility && !hasMoreToShow
|
||||
const showEmptyState = !isLoading && totalVisible === 0
|
||||
|
||||
return (
|
||||
<div className='px-3 pb-3 pt-2'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex w-full items-center rounded-md px-0 py-1 text-left text-text-primary'
|
||||
onClick={() => setIsCollapsed(prev => !prev)}
|
||||
>
|
||||
<span className='system-xs-medium text-text-primary'>{t('workflow.tabs.featuredTools')}</span>
|
||||
<ArrowDownRoundFill className={`ml-0.5 h-4 w-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} />
|
||||
</button>
|
||||
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{isLoading && (
|
||||
<div className='py-3'>
|
||||
<Loading type='app' />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showEmptyState && (
|
||||
<p className='system-xs-regular py-2 text-text-tertiary'>
|
||||
<Link className='text-text-accent' href={getMarketplaceUrl('', { category: 'trigger' })} target='_blank' rel='noopener noreferrer'>
|
||||
{t('workflow.tabs.noFeaturedTriggers')}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!showEmptyState && !isLoading && (
|
||||
<>
|
||||
{visibleInstalledProviders.length > 0 && (
|
||||
<div className='mt-1'>
|
||||
{visibleInstalledProviders.map(provider => (
|
||||
<TriggerPluginItem
|
||||
key={provider.id}
|
||||
payload={provider}
|
||||
hasSearchText={false}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visibleUninstalledPlugins.length > 0 && (
|
||||
<div className='mt-1 flex flex-col gap-1'>
|
||||
{visibleUninstalledPlugins.map(plugin => (
|
||||
<FeaturedTriggerUninstalledItem
|
||||
key={plugin.plugin_id}
|
||||
plugin={plugin}
|
||||
language={language}
|
||||
onInstallSuccess={async () => {
|
||||
await onInstallSuccess?.()
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!isLoading && totalVisible > 0 && canToggleVisibility && (
|
||||
<div
|
||||
className='group mt-1 flex cursor-pointer items-center gap-x-2 rounded-lg py-1 pl-3 pr-2 text-text-tertiary transition-colors hover:bg-state-base-hover hover:text-text-secondary'
|
||||
onClick={() => {
|
||||
setVisibleCount((count) => {
|
||||
if (count >= maxAvailable)
|
||||
return INITIAL_VISIBLE_COUNT
|
||||
|
||||
return Math.min(count + INITIAL_VISIBLE_COUNT, maxAvailable)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center px-1 text-text-tertiary transition-colors group-hover:text-text-secondary'>
|
||||
<RiMoreLine className='size-4 group-hover:hidden' />
|
||||
{isExpanded ? (
|
||||
<ArrowUpDoubleLine className='hidden size-4 group-hover:block' />
|
||||
) : (
|
||||
<ArrowDownDoubleLine className='hidden size-4 group-hover:block' />
|
||||
)}
|
||||
</div>
|
||||
<div className='system-xs-regular'>
|
||||
{t(isExpanded ? 'workflow.tabs.showLessFeatured' : 'workflow.tabs.showMoreFeatured')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type FeaturedTriggerUninstalledItemProps = {
|
||||
plugin: Plugin
|
||||
language: string
|
||||
onInstallSuccess?: () => Promise<void> | void
|
||||
t: (key: string, options?: Record<string, any>) => string
|
||||
}
|
||||
|
||||
function FeaturedTriggerUninstalledItem({
|
||||
plugin,
|
||||
language,
|
||||
onInstallSuccess,
|
||||
t,
|
||||
}: FeaturedTriggerUninstalledItemProps) {
|
||||
const label = plugin.label?.[language] || plugin.name
|
||||
const description = typeof plugin.brief === 'object' ? plugin.brief[language] : plugin.brief
|
||||
const installCountLabel = t('plugin.install', { num: formatNumber(plugin.install_count || 0) })
|
||||
const [actionOpen, setActionOpen] = useState(false)
|
||||
const [isActionHovered, setIsActionHovered] = useState(false)
|
||||
const [isInstallModalOpen, setIsInstallModalOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionOpen)
|
||||
return
|
||||
|
||||
const handleScroll = () => {
|
||||
setActionOpen(false)
|
||||
setIsActionHovered(false)
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
}
|
||||
}, [actionOpen])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
position='right'
|
||||
needsDelay={false}
|
||||
popupClassName='!p-0 !px-3 !py-2.5 !w-[224px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg'
|
||||
popupContent={(
|
||||
<div>
|
||||
<BlockIcon size='md' className='mb-2' type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
|
||||
<div className='mb-1 text-sm leading-5 text-text-primary'>{label}</div>
|
||||
<div className='text-xs leading-[18px] text-text-secondary'>{description}</div>
|
||||
</div>
|
||||
)}
|
||||
disabled={!description || isActionHovered || actionOpen || isInstallModalOpen}
|
||||
>
|
||||
<div
|
||||
className='group flex h-8 w-full items-center rounded-lg pl-3 pr-1 hover:bg-state-base-hover'
|
||||
>
|
||||
<div className='flex h-full min-w-0 items-center'>
|
||||
<BlockIcon type={BlockEnum.TriggerPlugin} toolIcon={plugin.icon} />
|
||||
<div className='ml-2 min-w-0'>
|
||||
<div className='system-sm-medium truncate text-text-secondary'>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='ml-auto flex h-full items-center gap-1 pl-1'>
|
||||
<span className={`system-xs-regular text-text-tertiary ${actionOpen ? 'hidden' : 'group-hover:hidden'}`}>{installCountLabel}</span>
|
||||
<div
|
||||
className={`system-xs-medium flex h-full items-center gap-1 text-components-button-secondary-accent-text [&_.action-btn]:h-6 [&_.action-btn]:min-h-0 [&_.action-btn]:w-6 [&_.action-btn]:rounded-lg [&_.action-btn]:p-0 ${actionOpen ? 'flex' : 'hidden group-hover:flex'}`}
|
||||
onMouseEnter={() => setIsActionHovered(true)}
|
||||
onMouseLeave={() => {
|
||||
if (!actionOpen)
|
||||
setIsActionHovered(false)
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type='button'
|
||||
className='cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover'
|
||||
onClick={() => {
|
||||
setActionOpen(false)
|
||||
setIsInstallModalOpen(true)
|
||||
setIsActionHovered(true)
|
||||
}}
|
||||
>
|
||||
{t('plugin.installAction')}
|
||||
</button>
|
||||
<Action
|
||||
open={actionOpen}
|
||||
onOpenChange={(value) => {
|
||||
setActionOpen(value)
|
||||
setIsActionHovered(value)
|
||||
}}
|
||||
author={plugin.org}
|
||||
name={plugin.name}
|
||||
version={plugin.latest_version}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{isInstallModalOpen && (
|
||||
<InstallFromMarketplace
|
||||
uniqueIdentifier={plugin.latest_package_identifier}
|
||||
manifest={plugin}
|
||||
onSuccess={async () => {
|
||||
setIsInstallModalOpen(false)
|
||||
setIsActionHovered(false)
|
||||
await onInstallSuccess?.()
|
||||
}}
|
||||
onClose={() => {
|
||||
setIsInstallModalOpen(false)
|
||||
setIsActionHovered(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeaturedTriggers
|
||||
156
dify/web/app/components/workflow/block-selector/hooks.ts
Normal file
156
dify/web/app/components/workflow/block-selector/hooks.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BLOCKS, START_BLOCKS } from './constants'
|
||||
import {
|
||||
TabsEnum,
|
||||
ToolTypeEnum,
|
||||
} from './types'
|
||||
|
||||
export const useBlocks = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return BLOCKS.map((block) => {
|
||||
return {
|
||||
...block,
|
||||
title: t(`workflow.blocks.${block.type}`),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const useStartBlocks = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return START_BLOCKS.map((block) => {
|
||||
return {
|
||||
...block,
|
||||
title: t(`workflow.blocks.${block.type}`),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const useTabs = ({
|
||||
noBlocks,
|
||||
noSources,
|
||||
noTools,
|
||||
noStart = true,
|
||||
defaultActiveTab,
|
||||
hasUserInputNode = false,
|
||||
forceEnableStartTab = false, // When true, Start tab remains enabled even if trigger/user input nodes already exist.
|
||||
}: {
|
||||
noBlocks?: boolean
|
||||
noSources?: boolean
|
||||
noTools?: boolean
|
||||
noStart?: boolean
|
||||
defaultActiveTab?: TabsEnum
|
||||
hasUserInputNode?: boolean
|
||||
forceEnableStartTab?: boolean
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const shouldShowStartTab = !noStart
|
||||
const shouldDisableStartTab = !forceEnableStartTab && hasUserInputNode
|
||||
const tabs = useMemo(() => {
|
||||
const tabConfigs = [{
|
||||
key: TabsEnum.Blocks,
|
||||
name: t('workflow.tabs.blocks'),
|
||||
show: !noBlocks,
|
||||
}, {
|
||||
key: TabsEnum.Sources,
|
||||
name: t('workflow.tabs.sources'),
|
||||
show: !noSources,
|
||||
}, {
|
||||
key: TabsEnum.Tools,
|
||||
name: t('workflow.tabs.tools'),
|
||||
show: !noTools,
|
||||
},
|
||||
{
|
||||
key: TabsEnum.Start,
|
||||
name: t('workflow.tabs.start'),
|
||||
show: shouldShowStartTab,
|
||||
disabled: shouldDisableStartTab,
|
||||
}]
|
||||
|
||||
return tabConfigs.filter(tab => tab.show)
|
||||
}, [t, noBlocks, noSources, noTools, shouldShowStartTab, shouldDisableStartTab])
|
||||
|
||||
const getValidTabKey = useCallback((targetKey?: TabsEnum) => {
|
||||
if (!targetKey)
|
||||
return undefined
|
||||
const tab = tabs.find(tabItem => tabItem.key === targetKey)
|
||||
if (!tab || tab.disabled)
|
||||
return undefined
|
||||
return tab.key
|
||||
}, [tabs])
|
||||
|
||||
const initialTab = useMemo(() => {
|
||||
const fallbackTab = tabs.find(tab => !tab.disabled)?.key ?? TabsEnum.Blocks
|
||||
const preferredDefault = getValidTabKey(defaultActiveTab)
|
||||
if (preferredDefault)
|
||||
return preferredDefault
|
||||
|
||||
const preferredOrder: TabsEnum[] = []
|
||||
if (!noBlocks)
|
||||
preferredOrder.push(TabsEnum.Blocks)
|
||||
if (!noTools)
|
||||
preferredOrder.push(TabsEnum.Tools)
|
||||
if (!noSources)
|
||||
preferredOrder.push(TabsEnum.Sources)
|
||||
if (!noStart)
|
||||
preferredOrder.push(TabsEnum.Start)
|
||||
|
||||
for (const tabKey of preferredOrder) {
|
||||
const validKey = getValidTabKey(tabKey)
|
||||
if (validKey)
|
||||
return validKey
|
||||
}
|
||||
|
||||
return fallbackTab
|
||||
}, [defaultActiveTab, noBlocks, noSources, noTools, noStart, tabs, getValidTabKey])
|
||||
const [activeTab, setActiveTab] = useState(initialTab)
|
||||
|
||||
useEffect(() => {
|
||||
const currentTab = tabs.find(tab => tab.key === activeTab)
|
||||
if (!currentTab || currentTab.disabled)
|
||||
setActiveTab(initialTab)
|
||||
}, [tabs, activeTab, initialTab])
|
||||
|
||||
return {
|
||||
tabs,
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
}
|
||||
}
|
||||
|
||||
export const useToolTabs = (isHideMCPTools?: boolean) => {
|
||||
const { t } = useTranslation()
|
||||
const tabs = [
|
||||
{
|
||||
key: ToolTypeEnum.All,
|
||||
name: t('workflow.tabs.allTool'),
|
||||
},
|
||||
{
|
||||
key: ToolTypeEnum.BuiltIn,
|
||||
name: t('workflow.tabs.plugin'),
|
||||
},
|
||||
{
|
||||
key: ToolTypeEnum.Custom,
|
||||
name: t('workflow.tabs.customTool'),
|
||||
},
|
||||
{
|
||||
key: ToolTypeEnum.Workflow,
|
||||
name: t('workflow.tabs.workflowTool'),
|
||||
},
|
||||
]
|
||||
if (!isHideMCPTools) {
|
||||
tabs.push({
|
||||
key: ToolTypeEnum.MCP,
|
||||
name: 'MCP',
|
||||
})
|
||||
}
|
||||
|
||||
return tabs
|
||||
}
|
||||
100
dify/web/app/components/workflow/block-selector/index-bar.tsx
Normal file
100
dify/web/app/components/workflow/block-selector/index-bar.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { pinyin } from 'pinyin-pro'
|
||||
import type { FC, RefObject } from 'react'
|
||||
import type { ToolWithProvider } from '../types'
|
||||
import { CollectionType } from '../../tools/types'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
export const CUSTOM_GROUP_NAME = '@@@custom@@@'
|
||||
export const WORKFLOW_GROUP_NAME = '@@@workflow@@@'
|
||||
export const DATA_SOURCE_GROUP_NAME = '@@@data_source@@@'
|
||||
export const AGENT_GROUP_NAME = '@@@agent@@@'
|
||||
/*
|
||||
{
|
||||
A: {
|
||||
'google': [ // plugin organize name
|
||||
...tools
|
||||
],
|
||||
'custom': [ // custom tools
|
||||
...tools
|
||||
],
|
||||
'workflow': [ // workflow as tools
|
||||
...tools
|
||||
]
|
||||
}
|
||||
}
|
||||
*/
|
||||
export const groupItems = (items: ToolWithProvider[], getFirstChar: (item: ToolWithProvider) => string) => {
|
||||
const groups = items.reduce((acc: Record<string, Record<string, ToolWithProvider[]>>, item) => {
|
||||
const firstChar = getFirstChar(item)
|
||||
if (!firstChar || firstChar.length === 0)
|
||||
return acc
|
||||
|
||||
let letter
|
||||
|
||||
// transform Chinese to pinyin
|
||||
if (/[\u4E00-\u9FA5]/.test(firstChar))
|
||||
letter = pinyin(firstChar, { pattern: 'first', toneType: 'none' })[0].toUpperCase()
|
||||
else
|
||||
letter = firstChar.toUpperCase()
|
||||
|
||||
if (!/[A-Z]/.test(letter))
|
||||
letter = '#'
|
||||
|
||||
if (!acc[letter])
|
||||
acc[letter] = {}
|
||||
|
||||
let groupName: string = ''
|
||||
if (item.type === CollectionType.builtIn)
|
||||
groupName = item.author
|
||||
else if (item.type === CollectionType.custom)
|
||||
groupName = CUSTOM_GROUP_NAME
|
||||
else if (item.type === CollectionType.workflow)
|
||||
groupName = WORKFLOW_GROUP_NAME
|
||||
else if (item.type === CollectionType.datasource)
|
||||
groupName = DATA_SOURCE_GROUP_NAME
|
||||
else
|
||||
groupName = AGENT_GROUP_NAME
|
||||
|
||||
if (!acc[letter][groupName])
|
||||
acc[letter][groupName] = []
|
||||
|
||||
acc[letter][groupName].push(item)
|
||||
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const letters = Object.keys(groups).sort()
|
||||
// move '#' to the end
|
||||
const hashIndex = letters.indexOf('#')
|
||||
if (hashIndex !== -1) {
|
||||
letters.splice(hashIndex, 1)
|
||||
letters.push('#')
|
||||
}
|
||||
return { letters, groups }
|
||||
}
|
||||
|
||||
type IndexBarProps = {
|
||||
letters: string[]
|
||||
itemRefs: RefObject<{ [key: string]: HTMLElement | null }>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const IndexBar: FC<IndexBarProps> = ({ letters, itemRefs, className }) => {
|
||||
const handleIndexClick = (letter: string) => {
|
||||
const element = itemRefs.current?.[letter]
|
||||
if (element)
|
||||
element.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
return (
|
||||
<div className={classNames('index-bar sticky top-[20px] flex h-full w-6 flex-col items-center justify-center text-xs font-medium text-text-quaternary', className)}>
|
||||
<div className={classNames('absolute left-0 top-0 h-full w-px bg-[linear-gradient(270deg,rgba(255,255,255,0)_0%,rgba(16,24,40,0.08)_30%,rgba(16,24,40,0.08)_50%,rgba(16,24,40,0.08)_70.5%,rgba(255,255,255,0)_100%)]')}></div>
|
||||
{letters.map(letter => (
|
||||
<div className="cursor-pointer hover:text-text-secondary" key={letter} onClick={() => handleIndexClick(letter)}>
|
||||
{letter}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default IndexBar
|
||||
49
dify/web/app/components/workflow/block-selector/index.tsx
Normal file
49
dify/web/app/components/workflow/block-selector/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import type { NodeSelectorProps } from './main'
|
||||
import NodeSelector from './main'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store/store'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useStore } from '../store'
|
||||
|
||||
const NodeSelectorWrapper = (props: NodeSelectorProps) => {
|
||||
const availableNodesMetaData = useHooksStore(s => s.availableNodesMetaData)
|
||||
const dataSourceList = useStore(s => s.dataSourceList)
|
||||
|
||||
const blocks = useMemo(() => {
|
||||
const result = availableNodesMetaData?.nodes || []
|
||||
|
||||
return result.filter((block) => {
|
||||
if (block.metaData.type === BlockEnum.Start)
|
||||
return false
|
||||
|
||||
if (block.metaData.type === BlockEnum.DataSource)
|
||||
return false
|
||||
|
||||
if (block.metaData.type === BlockEnum.Tool)
|
||||
return false
|
||||
|
||||
if (block.metaData.type === BlockEnum.IterationStart)
|
||||
return false
|
||||
|
||||
if (block.metaData.type === BlockEnum.LoopStart)
|
||||
return false
|
||||
|
||||
if (block.metaData.type === BlockEnum.DataSourceEmpty)
|
||||
return false
|
||||
|
||||
return true
|
||||
})
|
||||
}, [availableNodesMetaData?.nodes])
|
||||
|
||||
return (
|
||||
<NodeSelector
|
||||
{...props}
|
||||
blocks={props.blocks || blocks}
|
||||
dataSources={props.dataSources || dataSourceList || []}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default NodeSelectorWrapper
|
||||
278
dify/web/app/components/workflow/block-selector/main.tsx
Normal file
278
dify/web/app/components/workflow/block-selector/main.tsx
Normal file
@@ -0,0 +1,278 @@
|
||||
import type {
|
||||
FC,
|
||||
MouseEventHandler,
|
||||
} from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import type {
|
||||
OffsetOptions,
|
||||
Placement,
|
||||
} from '@floating-ui/react'
|
||||
import type {
|
||||
CommonNodeType,
|
||||
NodeDefault,
|
||||
OnSelectBlock,
|
||||
ToolWithProvider,
|
||||
} from '../types'
|
||||
import { BlockEnum, isTriggerNode } from '../types'
|
||||
import Tabs from './tabs'
|
||||
import { TabsEnum } from './types'
|
||||
import { useTabs } from './hooks'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Input from '@/app/components/base/input'
|
||||
import {
|
||||
Plus02,
|
||||
} from '@/app/components/base/icons/src/vender/line/general'
|
||||
import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||
|
||||
export type NodeSelectorProps = {
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
onSelect: OnSelectBlock
|
||||
trigger?: (open: boolean) => React.ReactNode
|
||||
placement?: Placement
|
||||
offset?: OffsetOptions
|
||||
triggerStyle?: React.CSSProperties
|
||||
triggerClassName?: (open: boolean) => string
|
||||
triggerInnerClassName?: string
|
||||
popupClassName?: string
|
||||
asChild?: boolean
|
||||
availableBlocksTypes?: BlockEnum[]
|
||||
disabled?: boolean
|
||||
blocks?: NodeDefault[]
|
||||
dataSources?: ToolWithProvider[]
|
||||
noBlocks?: boolean
|
||||
noTools?: boolean
|
||||
showStartTab?: boolean
|
||||
defaultActiveTab?: TabsEnum
|
||||
forceShowStartContent?: boolean
|
||||
ignoreNodeIds?: string[]
|
||||
forceEnableStartTab?: boolean // Force enabling Start tab regardless of existing trigger/user input nodes (e.g., when changing Start node type).
|
||||
allowUserInputSelection?: boolean // Override user-input availability; default logic blocks it when triggers exist.
|
||||
}
|
||||
const NodeSelector: FC<NodeSelectorProps> = ({
|
||||
open: openFromProps,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
trigger,
|
||||
placement = 'right',
|
||||
offset = 6,
|
||||
triggerClassName,
|
||||
triggerInnerClassName,
|
||||
triggerStyle,
|
||||
popupClassName,
|
||||
asChild,
|
||||
availableBlocksTypes,
|
||||
disabled,
|
||||
blocks = [],
|
||||
dataSources = [],
|
||||
noBlocks = false,
|
||||
noTools = false,
|
||||
showStartTab = false,
|
||||
defaultActiveTab,
|
||||
forceShowStartContent = false,
|
||||
ignoreNodeIds = [],
|
||||
forceEnableStartTab = false,
|
||||
allowUserInputSelection,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
// Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state.
|
||||
const filteredNodes = useMemo(() => {
|
||||
if (!ignoreNodeIds.length)
|
||||
return nodes
|
||||
const ignoreSet = new Set(ignoreNodeIds)
|
||||
return nodes.filter(node => !ignoreSet.has(node.id))
|
||||
}, [nodes, ignoreNodeIds])
|
||||
|
||||
const { hasTriggerNode, hasUserInputNode } = useMemo(() => {
|
||||
const result = {
|
||||
hasTriggerNode: false,
|
||||
hasUserInputNode: false,
|
||||
}
|
||||
for (const node of filteredNodes) {
|
||||
const nodeType = (node.data as CommonNodeType | undefined)?.type
|
||||
if (!nodeType)
|
||||
continue
|
||||
if (nodeType === BlockEnum.Start)
|
||||
result.hasUserInputNode = true
|
||||
if (isTriggerNode(nodeType))
|
||||
result.hasTriggerNode = true
|
||||
if (result.hasTriggerNode && result.hasUserInputNode)
|
||||
break
|
||||
}
|
||||
return result
|
||||
}, [filteredNodes])
|
||||
// Default rule: user input option is only available when no Start node nor Trigger node exists on canvas.
|
||||
const defaultAllowUserInputSelection = !hasUserInputNode && !hasTriggerNode
|
||||
const canSelectUserInput = allowUserInputSelection ?? defaultAllowUserInputSelection
|
||||
const open = openFromProps === undefined ? localOpen : openFromProps
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setLocalOpen(newOpen)
|
||||
|
||||
if (!newOpen)
|
||||
setSearchText('')
|
||||
|
||||
if (onOpenChange)
|
||||
onOpenChange(newOpen)
|
||||
}, [onOpenChange])
|
||||
const handleTrigger = useCallback<MouseEventHandler<HTMLDivElement>>((e) => {
|
||||
if (disabled)
|
||||
return
|
||||
e.stopPropagation()
|
||||
handleOpenChange(!open)
|
||||
}, [handleOpenChange, open, disabled])
|
||||
|
||||
const handleSelect = useCallback<OnSelectBlock>((type, pluginDefaultValue) => {
|
||||
handleOpenChange(false)
|
||||
onSelect(type, pluginDefaultValue)
|
||||
}, [handleOpenChange, onSelect])
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
tabs,
|
||||
} = useTabs({
|
||||
noBlocks,
|
||||
noSources: !dataSources.length,
|
||||
noTools,
|
||||
noStart: !showStartTab,
|
||||
defaultActiveTab,
|
||||
hasUserInputNode,
|
||||
forceEnableStartTab,
|
||||
})
|
||||
|
||||
const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => {
|
||||
setActiveTab(newActiveTab)
|
||||
}, [setActiveTab])
|
||||
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
if (activeTab === TabsEnum.Start)
|
||||
return t('workflow.tabs.searchTrigger')
|
||||
|
||||
if (activeTab === TabsEnum.Blocks)
|
||||
return t('workflow.tabs.searchBlock')
|
||||
|
||||
if (activeTab === TabsEnum.Tools)
|
||||
return t('workflow.tabs.searchTool')
|
||||
|
||||
if (activeTab === TabsEnum.Sources)
|
||||
return t('workflow.tabs.searchDataSource')
|
||||
return ''
|
||||
}, [activeTab, t])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
asChild={asChild}
|
||||
onClick={handleTrigger}
|
||||
className={triggerInnerClassName}
|
||||
>
|
||||
{
|
||||
trigger
|
||||
? trigger(open)
|
||||
: (
|
||||
<div
|
||||
className={`
|
||||
z-10 flex h-4
|
||||
w-4 cursor-pointer items-center justify-center rounded-full bg-components-button-primary-bg text-text-primary-on-surface hover:bg-components-button-primary-bg-hover
|
||||
${triggerClassName?.(open)}
|
||||
`}
|
||||
style={triggerStyle}
|
||||
>
|
||||
<Plus02 className='h-2.5 w-2.5' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1000]'>
|
||||
<div className={`rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg ${popupClassName}`}>
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
blocks={blocks}
|
||||
allowStartNodeSelection={canSelectUserInput}
|
||||
onActiveTabChange={handleActiveTabChange}
|
||||
filterElem={
|
||||
<div className='relative m-2' onClick={e => e.stopPropagation()}>
|
||||
{activeTab === TabsEnum.Start && (
|
||||
<SearchBox
|
||||
autoFocus
|
||||
search={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder={searchPlaceholder}
|
||||
inputClassName='grow'
|
||||
/>
|
||||
)}
|
||||
{activeTab === TabsEnum.Blocks && (
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
autoFocus
|
||||
value={searchText}
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onClear={() => setSearchText('')}
|
||||
/>
|
||||
)}
|
||||
{activeTab === TabsEnum.Sources && (
|
||||
<Input
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
autoFocus
|
||||
value={searchText}
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={e => setSearchText(e.target.value)}
|
||||
onClear={() => setSearchText('')}
|
||||
/>
|
||||
)}
|
||||
{activeTab === TabsEnum.Tools && (
|
||||
<SearchBox
|
||||
autoFocus
|
||||
search={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder={t('plugin.searchTools')!}
|
||||
inputClassName='grow'
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
onSelect={handleSelect}
|
||||
searchText={searchText}
|
||||
tags={tags}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
noBlocks={noBlocks}
|
||||
dataSources={dataSources}
|
||||
noTools={noTools}
|
||||
onTagsChange={setTags}
|
||||
forceShowStartContent={forceShowStartContent}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(NodeSelector)
|
||||
@@ -0,0 +1,99 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
// import Button from '@/app/components/base/button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useDownloadPlugin } from '@/service/use-plugins'
|
||||
import { downloadFile } from '@/utils/format'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onOpenChange: (v: boolean) => void
|
||||
author: string
|
||||
name: string
|
||||
version: string
|
||||
}
|
||||
|
||||
const OperationDropdown: FC<Props> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
author,
|
||||
name,
|
||||
version,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const queryClient = useQueryClient()
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
onOpenChange(v)
|
||||
openRef.current = v
|
||||
}, [onOpenChange])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
|
||||
const [needDownload, setNeedDownload] = useState(false)
|
||||
const downloadInfo = useMemo(() => ({
|
||||
organization: author,
|
||||
pluginName: name,
|
||||
version,
|
||||
}), [author, name, version])
|
||||
const { data: blob, isLoading } = useDownloadPlugin(downloadInfo, needDownload)
|
||||
const handleDownload = useCallback(() => {
|
||||
if (isLoading) return
|
||||
queryClient.removeQueries({
|
||||
queryKey: ['plugins', 'downloadPlugin', downloadInfo],
|
||||
exact: true,
|
||||
})
|
||||
setNeedDownload(true)
|
||||
}, [downloadInfo, isLoading, queryClient])
|
||||
|
||||
useEffect(() => {
|
||||
if (!needDownload || !blob)
|
||||
return
|
||||
const fileName = `${author}-${name}_${version}.zip`
|
||||
downloadFile({ data: blob, fileName })
|
||||
setNeedDownload(false)
|
||||
queryClient.removeQueries({
|
||||
queryKey: ['plugins', 'downloadPlugin', downloadInfo],
|
||||
exact: true,
|
||||
})
|
||||
}, [author, blob, downloadInfo, name, needDownload, queryClient, version])
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 0,
|
||||
crossAxis: 0,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<ActionButton className={cn(open && 'bg-state-base-hover')}>
|
||||
<RiMoreFill className='h-4 w-4 text-components-button-secondary-accent-text' />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[9999]'>
|
||||
<div className='min-w-[176px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg'>
|
||||
<div onClick={handleDownload} className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>{t('common.operation.download')}</div>
|
||||
<a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target='_blank' className='system-md-regular block cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>{t('common.operation.viewDetails')}</a>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(OperationDropdown)
|
||||
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Action from './action'
|
||||
import type { Plugin } from '@/app/components/plugins/types.ts'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import I18n from '@/context/i18n'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { useBoolean } from 'ahooks'
|
||||
|
||||
enum ActionType {
|
||||
install = 'install',
|
||||
download = 'download',
|
||||
// viewDetail = 'viewDetail', // wait for marketplace api
|
||||
}
|
||||
type Props = {
|
||||
payload: Plugin
|
||||
onAction: (type: ActionType) => void
|
||||
}
|
||||
|
||||
const Item: FC<Props> = ({
|
||||
payload,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const { locale } = useContext(I18n)
|
||||
const getLocalizedText = (obj: Record<string, string> | undefined) =>
|
||||
obj?.[locale] || obj?.['en-US'] || obj?.en_US || ''
|
||||
const [isShowInstallModal, {
|
||||
setTrue: showInstallModal,
|
||||
setFalse: hideInstallModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
return (
|
||||
<div className='group/plugin flex rounded-lg py-1 pl-3 pr-1 hover:bg-state-base-hover'>
|
||||
<div
|
||||
className='relative h-6 w-6 shrink-0 rounded-md border-[0.5px] border-components-panel-border-subtle bg-contain bg-center bg-no-repeat'
|
||||
style={{ backgroundImage: `url(${payload.icon})` }}
|
||||
/>
|
||||
<div className='ml-2 flex w-0 grow'>
|
||||
<div className='w-0 grow'>
|
||||
<div className='system-sm-medium h-4 truncate leading-4 text-text-primary '>{getLocalizedText(payload.label)}</div>
|
||||
<div className='system-xs-regular h-5 truncate leading-5 text-text-tertiary'>{getLocalizedText(payload.brief)}</div>
|
||||
<div className='system-xs-regular flex space-x-1 text-text-tertiary'>
|
||||
<div>{payload.org}</div>
|
||||
<div>·</div>
|
||||
<div>{t('plugin.install', { num: formatNumber(payload.install_count || 0) })}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Action */}
|
||||
<div className={cn(!open ? 'hidden' : 'flex', 'system-xs-medium h-4 items-center space-x-1 text-components-button-secondary-accent-text group-hover/plugin:flex')}>
|
||||
<div
|
||||
className='cursor-pointer rounded-md px-1.5 py-0.5 hover:bg-state-base-hover'
|
||||
onClick={showInstallModal}
|
||||
>
|
||||
{t('plugin.installAction')}
|
||||
</div>
|
||||
<Action
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
author={payload.org}
|
||||
name={payload.name}
|
||||
version={payload.latest_version}
|
||||
/>
|
||||
</div>
|
||||
{isShowInstallModal && (
|
||||
<InstallFromMarketplace
|
||||
uniqueIdentifier={payload.latest_package_identifier}
|
||||
manifest={payload}
|
||||
onSuccess={hideInstallModal}
|
||||
onClose={hideInstallModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Item)
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client'
|
||||
import { useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useStickyScroll, { ScrollPosition } from '../use-sticky-scroll'
|
||||
import Item from './item'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import cn from '@/utils/classnames'
|
||||
import Link from 'next/link'
|
||||
import { RiArrowRightUpLine, RiSearchLine } from '@remixicon/react'
|
||||
import { noop } from 'lodash-es'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
|
||||
export type ListProps = {
|
||||
wrapElemRef: React.RefObject<HTMLElement | null>
|
||||
list: Plugin[]
|
||||
searchText: string
|
||||
tags: string[]
|
||||
toolContentClassName?: string
|
||||
disableMaxWidth?: boolean
|
||||
hideFindMoreFooter?: boolean
|
||||
ref?: React.Ref<ListRef>
|
||||
}
|
||||
|
||||
export type ListRef = { handleScroll: () => void }
|
||||
|
||||
const List = ({
|
||||
wrapElemRef,
|
||||
searchText,
|
||||
tags,
|
||||
list,
|
||||
toolContentClassName,
|
||||
disableMaxWidth = false,
|
||||
hideFindMoreFooter = false,
|
||||
ref,
|
||||
}: ListProps) => {
|
||||
const { t } = useTranslation()
|
||||
const noFilter = !searchText && tags.length === 0
|
||||
const hasRes = list.length > 0
|
||||
const urlWithSearchText = getMarketplaceUrl('', { q: searchText, tags: tags.join(',') })
|
||||
const nextToStickyELemRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { handleScroll, scrollPosition } = useStickyScroll({
|
||||
wrapElemRef,
|
||||
nextToStickyELemRef: nextToStickyELemRef as RefObject<HTMLElement>,
|
||||
})
|
||||
const stickyClassName = useMemo(() => {
|
||||
switch (scrollPosition) {
|
||||
case ScrollPosition.aboveTheWrap:
|
||||
return 'top-0 h-9 pt-3 pb-2 shadow-xs bg-components-panel-bg-blur cursor-pointer'
|
||||
case ScrollPosition.showing:
|
||||
return 'bottom-0 pt-3 pb-1'
|
||||
case ScrollPosition.belowTheWrap:
|
||||
return 'bottom-0 items-center rounded-b-xl border-t border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg rounded-b-lg cursor-pointer'
|
||||
}
|
||||
}, [scrollPosition])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
handleScroll,
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll()
|
||||
}, [list])
|
||||
|
||||
const handleHeadClick = () => {
|
||||
if (scrollPosition === ScrollPosition.belowTheWrap) {
|
||||
nextToStickyELemRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
return
|
||||
}
|
||||
window.open(urlWithSearchText, '_blank')
|
||||
}
|
||||
|
||||
if (noFilter) {
|
||||
if (hideFindMoreFooter)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Link
|
||||
className='system-sm-medium sticky bottom-0 z-10 flex h-8 cursor-pointer items-center rounded-b-lg border-[0.5px] border-t border-components-panel-border bg-components-panel-bg-blur px-4 py-1 text-text-accent-light-mode-only shadow-lg'
|
||||
href={getMarketplaceUrl('')}
|
||||
target='_blank'
|
||||
>
|
||||
<span>{t('plugin.findMoreInMarketplace')}</span>
|
||||
<RiArrowRightUpLine className='ml-0.5 h-3 w-3' />
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const maxWidthClassName = toolContentClassName || 'max-w-[100%]'
|
||||
|
||||
return (
|
||||
<>
|
||||
{hasRes && (
|
||||
<div
|
||||
className={cn('system-sm-medium sticky z-10 flex h-8 cursor-pointer justify-between px-4 py-1 text-text-primary', stickyClassName, !disableMaxWidth && maxWidthClassName)}
|
||||
onClick={handleHeadClick}
|
||||
>
|
||||
<span>{t('plugin.fromMarketplace')}</span>
|
||||
<Link
|
||||
href={urlWithSearchText}
|
||||
target='_blank'
|
||||
className='flex items-center text-text-accent-light-mode-only'
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<span>{t('plugin.searchInMarketplace')}</span>
|
||||
<RiArrowRightUpLine className='ml-0.5 h-3 w-3' />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('p-1', !disableMaxWidth && maxWidthClassName)} ref={nextToStickyELemRef}>
|
||||
{list.map((item, index) => (
|
||||
<Item
|
||||
key={index}
|
||||
payload={item}
|
||||
onAction={noop}
|
||||
/>
|
||||
))}
|
||||
{hasRes && (
|
||||
<div className='mb-3 mt-2 flex items-center justify-center space-x-2'>
|
||||
<div className="h-[2px] w-[90px] bg-gradient-to-l from-[rgba(16,24,40,0.08)] to-[rgba(255,255,255,0.01)]"></div>
|
||||
<Link
|
||||
href={urlWithSearchText}
|
||||
target='_blank'
|
||||
className='system-sm-medium flex h-4 shrink-0 items-center text-text-accent-light-mode-only'
|
||||
>
|
||||
<RiSearchLine className='mr-0.5 h-3 w-3' />
|
||||
<span>{t('plugin.searchInMarketplace')}</span>
|
||||
</Link>
|
||||
<div className="h-[2px] w-[90px] bg-gradient-to-l from-[rgba(255,255,255,0.01)] to-[rgba(16,24,40,0.08)]"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
List.displayName = 'List'
|
||||
|
||||
export default List
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client'
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import type { OnSelectBlock } from '@/app/components/workflow/types'
|
||||
import type { ViewType } from '@/app/components/workflow/block-selector/view-type-select'
|
||||
import { RiMoreLine } from '@remixicon/react'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Link from 'next/link'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import { useRAGRecommendedPlugins } from '@/service/use-tools'
|
||||
import List from './list'
|
||||
import { getFormattedPlugin } from '@/app/components/plugins/marketplace/utils'
|
||||
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/arrows'
|
||||
|
||||
type RAGToolRecommendationsProps = {
|
||||
viewType: ViewType
|
||||
onSelect: OnSelectBlock
|
||||
onTagsChange: Dispatch<SetStateAction<string[]>>
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'workflow_rag_recommendations_collapsed'
|
||||
|
||||
const RAGToolRecommendations = ({
|
||||
viewType,
|
||||
onSelect,
|
||||
onTagsChange,
|
||||
}: RAGToolRecommendationsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined')
|
||||
return false
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
return stored === 'true'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY)
|
||||
if (stored !== null)
|
||||
setIsCollapsed(stored === 'true')
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
window.localStorage.setItem(STORAGE_KEY, String(isCollapsed))
|
||||
}, [isCollapsed])
|
||||
|
||||
const {
|
||||
data: ragRecommendedPlugins,
|
||||
isLoading: isLoadingRAGRecommendedPlugins,
|
||||
isFetching: isFetchingRAGRecommendedPlugins,
|
||||
} = useRAGRecommendedPlugins()
|
||||
|
||||
const recommendedPlugins = useMemo(() => {
|
||||
if (ragRecommendedPlugins)
|
||||
return ragRecommendedPlugins.installed_recommended_plugins
|
||||
return []
|
||||
}, [ragRecommendedPlugins])
|
||||
|
||||
const unInstalledPlugins = useMemo(() => {
|
||||
if (ragRecommendedPlugins)
|
||||
return (ragRecommendedPlugins.uninstalled_recommended_plugins).map(getFormattedPlugin)
|
||||
return []
|
||||
}, [ragRecommendedPlugins])
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
onTagsChange((prev) => {
|
||||
if (prev.includes('rag'))
|
||||
return prev
|
||||
return [...prev, 'rag']
|
||||
})
|
||||
}, [onTagsChange])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col p-1'>
|
||||
<button
|
||||
type='button'
|
||||
className='flex w-full items-center rounded-md px-3 pb-0.5 pt-1 text-left text-text-tertiary'
|
||||
onClick={() => setIsCollapsed(prev => !prev)}
|
||||
>
|
||||
<span className='system-xs-medium text-text-tertiary'>{t('pipeline.ragToolSuggestions.title')}</span>
|
||||
<ArrowDownRoundFill className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isCollapsed ? '-rotate-90' : 'rotate-0'}`} />
|
||||
</button>
|
||||
{!isCollapsed && (
|
||||
<>
|
||||
{/* For first time loading, show loading */}
|
||||
{isLoadingRAGRecommendedPlugins && (
|
||||
<div className='py-2'>
|
||||
<Loading type='app' />
|
||||
</div>
|
||||
)}
|
||||
{!isFetchingRAGRecommendedPlugins && recommendedPlugins.length === 0 && unInstalledPlugins.length === 0 && (
|
||||
<p className='system-xs-regular px-3 py-1 text-text-tertiary'>
|
||||
<Trans
|
||||
i18nKey='pipeline.ragToolSuggestions.noRecommendationPlugins'
|
||||
components={{
|
||||
CustomLink: (
|
||||
<Link
|
||||
className='text-text-accent'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
href={getMarketplaceUrl('', { tags: 'rag' })}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</p>
|
||||
)}
|
||||
{(recommendedPlugins.length > 0 || unInstalledPlugins.length > 0) && (
|
||||
<>
|
||||
<List
|
||||
tools={recommendedPlugins}
|
||||
unInstalledPlugins={unInstalledPlugins}
|
||||
onSelect={onSelect}
|
||||
viewType={viewType}
|
||||
/>
|
||||
<div
|
||||
className='flex cursor-pointer items-center gap-x-2 py-1 pl-3 pr-2'
|
||||
onClick={loadMore}
|
||||
>
|
||||
<div className='px-1'>
|
||||
<RiMoreLine className='size-4 text-text-tertiary' />
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{t('common.operation.more')}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(RAGToolRecommendations)
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useMemo, useRef } from 'react'
|
||||
import type { BlockEnum, ToolWithProvider } from '../../types'
|
||||
import type { ToolDefaultValue } from '../types'
|
||||
import { ViewType } from '../view-type-select'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { groupItems } from '../index-bar'
|
||||
import cn from '@/utils/classnames'
|
||||
import ToolListTreeView from '../tool/tool-list-tree-view/list'
|
||||
import ToolListFlatView from '../tool/tool-list-flat-view/list'
|
||||
import UninstalledItem from './uninstalled-item'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import type { OnSelectBlock } from '@/app/components/workflow/types'
|
||||
|
||||
type ListProps = {
|
||||
onSelect: OnSelectBlock
|
||||
tools: ToolWithProvider[]
|
||||
viewType: ViewType
|
||||
unInstalledPlugins: Plugin[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
const List = ({
|
||||
onSelect,
|
||||
tools,
|
||||
viewType,
|
||||
unInstalledPlugins,
|
||||
className,
|
||||
}: ListProps) => {
|
||||
const language = useGetLanguage()
|
||||
const isFlatView = viewType === ViewType.flat
|
||||
|
||||
const { letters, groups: withLetterAndGroupViewToolsData } = groupItems(tools, tool => tool.label[language][0])
|
||||
const treeViewToolsData = useMemo(() => {
|
||||
const result: Record<string, ToolWithProvider[]> = {}
|
||||
Object.keys(withLetterAndGroupViewToolsData).forEach((letter) => {
|
||||
Object.keys(withLetterAndGroupViewToolsData[letter]).forEach((groupName) => {
|
||||
if (!result[groupName])
|
||||
result[groupName] = []
|
||||
result[groupName].push(...withLetterAndGroupViewToolsData[letter][groupName])
|
||||
})
|
||||
})
|
||||
return result
|
||||
}, [withLetterAndGroupViewToolsData])
|
||||
|
||||
const listViewToolData = useMemo(() => {
|
||||
const result: ToolWithProvider[] = []
|
||||
letters.forEach((letter) => {
|
||||
Object.keys(withLetterAndGroupViewToolsData[letter]).forEach((groupName) => {
|
||||
result.push(...withLetterAndGroupViewToolsData[letter][groupName].map((item) => {
|
||||
return {
|
||||
...item,
|
||||
letter,
|
||||
}
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}, [withLetterAndGroupViewToolsData, letters])
|
||||
|
||||
const toolRefs = useRef({})
|
||||
|
||||
const handleSelect = useCallback((type: BlockEnum, tool: ToolDefaultValue) => {
|
||||
onSelect(type, tool)
|
||||
}, [onSelect])
|
||||
|
||||
return (
|
||||
<div className={cn('max-w-[100%] p-1', className)}>
|
||||
{!!tools.length && (
|
||||
isFlatView ? (
|
||||
<ToolListFlatView
|
||||
toolRefs={toolRefs}
|
||||
letters={letters}
|
||||
payload={listViewToolData}
|
||||
isShowLetterIndex={false}
|
||||
hasSearchText={false}
|
||||
onSelect={handleSelect}
|
||||
canNotSelectMultiple
|
||||
indexBar={null}
|
||||
/>
|
||||
) : (
|
||||
<ToolListTreeView
|
||||
payload={treeViewToolsData}
|
||||
hasSearchText={false}
|
||||
onSelect={handleSelect}
|
||||
canNotSelectMultiple
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{
|
||||
unInstalledPlugins.map((item) => {
|
||||
return (
|
||||
<UninstalledItem
|
||||
key={item.plugin_id}
|
||||
payload={item}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default List
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { Plugin } from '@/app/components/plugins/types'
|
||||
import InstallFromMarketplace from '@/app/components/plugins/install-plugin/install-from-marketplace'
|
||||
import I18n from '@/context/i18n'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { BlockEnum } from '../../types'
|
||||
import BlockIcon from '../../block-icon'
|
||||
|
||||
type UninstalledItemProps = {
|
||||
payload: Plugin
|
||||
}
|
||||
|
||||
const UninstalledItem = ({
|
||||
payload,
|
||||
}: UninstalledItemProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { locale } = useContext(I18n)
|
||||
|
||||
const getLocalizedText = (obj: Record<string, string> | undefined) =>
|
||||
obj?.[locale] || obj?.['en-US'] || obj?.en_US || ''
|
||||
const [isShowInstallModal, {
|
||||
setTrue: showInstallModal,
|
||||
setFalse: hideInstallModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
return (
|
||||
<div className='flex h-8 items-center rounded-lg pl-3 pr-2 hover:bg-state-base-hover'>
|
||||
<BlockIcon
|
||||
className='shrink-0'
|
||||
type={BlockEnum.Tool}
|
||||
toolIcon={payload.icon}
|
||||
/>
|
||||
<div className='ml-2 flex w-0 grow items-center'>
|
||||
<div className='flex w-0 grow items-center gap-x-2'>
|
||||
<span className='system-sm-regular truncate text-text-primary'>
|
||||
{getLocalizedText(payload.label)}
|
||||
</span>
|
||||
<span className='system-xs-regular text-text-quaternary'>
|
||||
{payload.org}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className='system-xs-medium cursor-pointer pl-1.5 text-components-button-secondary-accent-text'
|
||||
onClick={showInstallModal}
|
||||
>
|
||||
{t('plugin.installAction')}
|
||||
</div>
|
||||
{isShowInstallModal && (
|
||||
<InstallFromMarketplace
|
||||
uniqueIdentifier={payload.latest_package_identifier}
|
||||
manifest={payload}
|
||||
onSuccess={hideInstallModal}
|
||||
onClose={hideInstallModal}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(UninstalledItem)
|
||||
139
dify/web/app/components/workflow/block-selector/start-blocks.tsx
Normal file
139
dify/web/app/components/workflow/block-selector/start-blocks.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import BlockIcon from '../block-icon'
|
||||
import type { BlockEnum, CommonNodeType } from '../types'
|
||||
import { BlockEnum as BlockEnumValues } from '../types'
|
||||
// import { useNodeMetaData } from '../hooks'
|
||||
import { START_BLOCKS } from './constants'
|
||||
import type { TriggerDefaultValue } from './types'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useAvailableNodesMetaData } from '../../workflow-app/hooks'
|
||||
|
||||
type StartBlocksProps = {
|
||||
searchText: string
|
||||
onSelect: (type: BlockEnum, triggerDefaultValue?: TriggerDefaultValue) => void
|
||||
availableBlocksTypes?: BlockEnum[]
|
||||
onContentStateChange?: (hasContent: boolean) => void
|
||||
hideUserInput?: boolean
|
||||
}
|
||||
|
||||
const StartBlocks = ({
|
||||
searchText,
|
||||
onSelect,
|
||||
availableBlocksTypes = [],
|
||||
onContentStateChange,
|
||||
hideUserInput = false, // Allow parent to explicitly hide Start node option (e.g. when one already exists).
|
||||
}: StartBlocksProps) => {
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes()
|
||||
// const nodeMetaData = useNodeMetaData()
|
||||
const availableNodesMetaData = useAvailableNodesMetaData()
|
||||
|
||||
const filteredBlocks = useMemo(() => {
|
||||
// Check if Start node already exists in workflow
|
||||
const hasStartNode = nodes.some(node => (node.data as CommonNodeType)?.type === BlockEnumValues.Start)
|
||||
const normalizedSearch = searchText.toLowerCase()
|
||||
const getDisplayName = (blockType: BlockEnum) => {
|
||||
if (blockType === BlockEnumValues.TriggerWebhook)
|
||||
return t('workflow.customWebhook')
|
||||
|
||||
return t(`workflow.blocks.${blockType}`)
|
||||
}
|
||||
|
||||
return START_BLOCKS.filter((block) => {
|
||||
// Hide User Input (Start) if it already exists in workflow or if hideUserInput is true
|
||||
if (block.type === BlockEnumValues.Start && (hasStartNode || hideUserInput))
|
||||
return false
|
||||
|
||||
// Filter by search text
|
||||
const displayName = getDisplayName(block.type).toLowerCase()
|
||||
if (!displayName.includes(normalizedSearch) && !block.title.toLowerCase().includes(normalizedSearch))
|
||||
return false
|
||||
|
||||
// availableBlocksTypes now contains properly filtered entry node types from parent
|
||||
return availableBlocksTypes.includes(block.type)
|
||||
})
|
||||
}, [searchText, availableBlocksTypes, nodes, t, hideUserInput])
|
||||
|
||||
const isEmpty = filteredBlocks.length === 0
|
||||
|
||||
useEffect(() => {
|
||||
onContentStateChange?.(!isEmpty)
|
||||
}, [isEmpty, onContentStateChange])
|
||||
|
||||
const renderBlock = useCallback((block: { type: BlockEnum; title: string; description?: string }) => (
|
||||
<Tooltip
|
||||
key={block.type}
|
||||
position='right'
|
||||
popupClassName='w-[224px] rounded-xl'
|
||||
needsDelay={false}
|
||||
popupContent={(
|
||||
<div>
|
||||
<BlockIcon
|
||||
size='md'
|
||||
className='mb-2'
|
||||
type={block.type}
|
||||
/>
|
||||
<div className='system-md-medium mb-1 text-text-primary'>
|
||||
{block.type === BlockEnumValues.TriggerWebhook
|
||||
? t('workflow.customWebhook')
|
||||
: t(`workflow.blocks.${block.type}`)
|
||||
}
|
||||
</div>
|
||||
<div className='system-xs-regular text-text-secondary'>
|
||||
{t(`workflow.blocksAbout.${block.type}`)}
|
||||
</div>
|
||||
{(block.type === BlockEnumValues.TriggerWebhook || block.type === BlockEnumValues.TriggerSchedule) && (
|
||||
<div className='system-xs-regular mb-1 mt-1 text-text-tertiary'>
|
||||
{t('tools.author')} {t('workflow.difyTeam')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className='flex h-8 w-full cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover'
|
||||
onClick={() => onSelect(block.type)}
|
||||
>
|
||||
<BlockIcon
|
||||
className='mr-2 shrink-0'
|
||||
type={block.type}
|
||||
/>
|
||||
<div className='flex w-0 grow items-center justify-between text-sm text-text-secondary'>
|
||||
<span className='truncate'>{t(`workflow.blocks.${block.type}`)}</span>
|
||||
{block.type === BlockEnumValues.Start && (
|
||||
<span className='system-xs-regular ml-2 shrink-0 text-text-quaternary'>{t('workflow.blocks.originalStartNode')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
), [availableNodesMetaData, onSelect, t])
|
||||
|
||||
if (isEmpty)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className='p-1'>
|
||||
<div className='mb-1'>
|
||||
{filteredBlocks.map((block, index) => (
|
||||
<div key={block.type}>
|
||||
{renderBlock(block)}
|
||||
{block.type === BlockEnumValues.Start && index < filteredBlocks.length - 1 && (
|
||||
<div className='my-1 px-3'>
|
||||
<div className='border-t border-divider-subtle' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(StartBlocks)
|
||||
242
dify/web/app/components/workflow/block-selector/tabs.tsx
Normal file
242
dify/web/app/components/workflow/block-selector/tabs.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||
import { memo, useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools, useInvalidateAllBuiltInTools } from '@/service/use-tools'
|
||||
import type {
|
||||
BlockEnum,
|
||||
NodeDefault,
|
||||
OnSelectBlock,
|
||||
ToolWithProvider,
|
||||
} from '../types'
|
||||
import { TabsEnum } from './types'
|
||||
import Blocks from './blocks'
|
||||
import AllStartBlocks from './all-start-blocks'
|
||||
import AllTools from './all-tools'
|
||||
import DataSources from './data-sources'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import { basePath } from '@/utils/var'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
|
||||
export type TabsProps = {
|
||||
activeTab: TabsEnum
|
||||
onActiveTabChange: (activeTab: TabsEnum) => void
|
||||
searchText: string
|
||||
tags: string[]
|
||||
onTagsChange: Dispatch<SetStateAction<string[]>>
|
||||
onSelect: OnSelectBlock
|
||||
availableBlocksTypes?: BlockEnum[]
|
||||
blocks: NodeDefault[]
|
||||
dataSources?: ToolWithProvider[]
|
||||
tabs: Array<{
|
||||
key: TabsEnum
|
||||
name: string
|
||||
disabled?: boolean
|
||||
}>
|
||||
filterElem: React.ReactNode
|
||||
noBlocks?: boolean
|
||||
noTools?: boolean
|
||||
forceShowStartContent?: boolean // Force show Start content even when noBlocks=true
|
||||
allowStartNodeSelection?: boolean // Allow user input option even when trigger node already exists (e.g. change-node flow or when no Start node yet).
|
||||
}
|
||||
const Tabs: FC<TabsProps> = ({
|
||||
activeTab,
|
||||
onActiveTabChange,
|
||||
tags,
|
||||
onTagsChange,
|
||||
searchText,
|
||||
onSelect,
|
||||
availableBlocksTypes,
|
||||
blocks,
|
||||
dataSources = [],
|
||||
tabs = [],
|
||||
filterElem,
|
||||
noBlocks,
|
||||
noTools,
|
||||
forceShowStartContent = false,
|
||||
allowStartNodeSelection = false,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const invalidateBuiltInTools = useInvalidateAllBuiltInTools()
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const inRAGPipeline = dataSources.length > 0
|
||||
const {
|
||||
plugins: featuredPlugins = [],
|
||||
isLoading: isFeaturedLoading,
|
||||
} = useFeaturedToolsRecommendations(enable_marketplace && !inRAGPipeline)
|
||||
|
||||
const normalizeToolList = useMemo(() => {
|
||||
return (list?: ToolWithProvider[]) => {
|
||||
if (!list)
|
||||
return list
|
||||
if (!basePath)
|
||||
return list
|
||||
let changed = false
|
||||
const normalized = list.map((provider) => {
|
||||
if (typeof provider.icon === 'string') {
|
||||
const icon = provider.icon
|
||||
const shouldPrefix = Boolean(basePath)
|
||||
&& icon.startsWith('/')
|
||||
&& !icon.startsWith(`${basePath}/`)
|
||||
|
||||
if (shouldPrefix) {
|
||||
changed = true
|
||||
return {
|
||||
...provider,
|
||||
icon: `${basePath}${icon}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
return provider
|
||||
})
|
||||
return changed ? normalized : list
|
||||
}
|
||||
}, [basePath])
|
||||
|
||||
useEffect(() => {
|
||||
workflowStore.setState((state) => {
|
||||
const updates: Partial<typeof state> = {}
|
||||
const normalizedBuiltIn = normalizeToolList(buildInTools)
|
||||
const normalizedCustom = normalizeToolList(customTools)
|
||||
const normalizedWorkflow = normalizeToolList(workflowTools)
|
||||
const normalizedMCP = normalizeToolList(mcpTools)
|
||||
|
||||
if (normalizedBuiltIn !== undefined && state.buildInTools !== normalizedBuiltIn)
|
||||
updates.buildInTools = normalizedBuiltIn
|
||||
if (normalizedCustom !== undefined && state.customTools !== normalizedCustom)
|
||||
updates.customTools = normalizedCustom
|
||||
if (normalizedWorkflow !== undefined && state.workflowTools !== normalizedWorkflow)
|
||||
updates.workflowTools = normalizedWorkflow
|
||||
if (normalizedMCP !== undefined && state.mcpTools !== normalizedMCP)
|
||||
updates.mcpTools = normalizedMCP
|
||||
if (!Object.keys(updates).length)
|
||||
return state
|
||||
return {
|
||||
...state,
|
||||
...updates,
|
||||
}
|
||||
})
|
||||
}, [workflowStore, normalizeToolList, buildInTools, customTools, workflowTools, mcpTools])
|
||||
|
||||
return (
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
{
|
||||
!noBlocks && (
|
||||
<div className='relative flex bg-background-section-burn pl-1 pt-1'>
|
||||
{
|
||||
tabs.map((tab) => {
|
||||
const commonProps = {
|
||||
'className': cn(
|
||||
'system-sm-medium relative mr-0.5 flex h-8 items-center rounded-t-lg px-3',
|
||||
tab.disabled
|
||||
? 'cursor-not-allowed text-text-disabled opacity-60'
|
||||
: activeTab === tab.key
|
||||
? 'sm-no-bottom cursor-default bg-components-panel-bg text-text-accent'
|
||||
: 'cursor-pointer text-text-tertiary',
|
||||
),
|
||||
'aria-disabled': tab.disabled,
|
||||
'onClick': () => {
|
||||
if (tab.disabled || activeTab === tab.key)
|
||||
return
|
||||
onActiveTabChange(tab.key)
|
||||
},
|
||||
} as const
|
||||
if (tab.disabled) {
|
||||
return (
|
||||
<Tooltip
|
||||
key={tab.key}
|
||||
position='top'
|
||||
popupClassName='max-w-[200px]'
|
||||
popupContent={t('workflow.tabs.startDisabledTip')}
|
||||
>
|
||||
<div {...commonProps}>
|
||||
{tab.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={tab.key}
|
||||
{...commonProps}
|
||||
>
|
||||
{tab.name}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{filterElem}
|
||||
{
|
||||
activeTab === TabsEnum.Start && (!noBlocks || forceShowStartContent) && (
|
||||
<div className='border-t border-divider-subtle'>
|
||||
<AllStartBlocks
|
||||
allowUserInputSelection={allowStartNodeSelection}
|
||||
searchText={searchText}
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
tags={tags}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
activeTab === TabsEnum.Blocks && !noBlocks && (
|
||||
<div className='border-t border-divider-subtle'>
|
||||
<Blocks
|
||||
searchText={searchText}
|
||||
onSelect={onSelect}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
blocks={blocks}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
activeTab === TabsEnum.Sources && !!dataSources.length && (
|
||||
<div className='border-t border-divider-subtle'>
|
||||
<DataSources
|
||||
searchText={searchText}
|
||||
onSelect={onSelect}
|
||||
dataSources={dataSources}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
activeTab === TabsEnum.Tools && !noTools && (
|
||||
<AllTools
|
||||
searchText={searchText}
|
||||
onSelect={onSelect}
|
||||
tags={tags}
|
||||
canNotSelectMultiple
|
||||
buildInTools={buildInTools || []}
|
||||
customTools={customTools || []}
|
||||
workflowTools={workflowTools || []}
|
||||
mcpTools={mcpTools || []}
|
||||
canChooseMCPTool
|
||||
onTagsChange={onTagsChange}
|
||||
isInRAGPipeline={inRAGPipeline}
|
||||
featuredPlugins={featuredPlugins}
|
||||
featuredLoading={isFeaturedLoading}
|
||||
showFeatured={enable_marketplace && !inRAGPipeline}
|
||||
onFeaturedInstallSuccess={async () => {
|
||||
invalidateBuiltInTools()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Tabs)
|
||||
218
dify/web/app/components/workflow/block-selector/tool-picker.tsx
Normal file
218
dify/web/app/components/workflow/block-selector/tool-picker.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import type {
|
||||
OffsetOptions,
|
||||
Placement,
|
||||
} from '@floating-ui/react'
|
||||
import AllTools from '@/app/components/workflow/block-selector/all-tools'
|
||||
import type { ToolDefaultValue, ToolValue } from './types'
|
||||
import type { BlockEnum, OnSelectBlock } from '@/app/components/workflow/types'
|
||||
import SearchBox from '@/app/components/plugins/marketplace/search-box'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
|
||||
import {
|
||||
createCustomCollection,
|
||||
} from '@/service/tools'
|
||||
import type { CustomCollectionBackend } from '@/app/components/tools/types'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
useInvalidateAllBuiltInTools,
|
||||
useInvalidateAllCustomTools,
|
||||
useInvalidateAllMCPTools,
|
||||
useInvalidateAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
import { useFeaturedToolsRecommendations } from '@/service/use-plugins'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
panelClassName?: string
|
||||
disabled: boolean
|
||||
trigger: React.ReactNode
|
||||
placement?: Placement
|
||||
offset?: OffsetOptions
|
||||
isShow: boolean
|
||||
onShowChange: (isShow: boolean) => void
|
||||
onSelect: (tool: ToolDefaultValue) => void
|
||||
onSelectMultiple: (tools: ToolDefaultValue[]) => void
|
||||
supportAddCustomTool?: boolean
|
||||
scope?: string
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
|
||||
const ToolPicker: FC<Props> = ({
|
||||
disabled,
|
||||
trigger,
|
||||
placement = 'right-start',
|
||||
offset = 0,
|
||||
isShow,
|
||||
onShowChange,
|
||||
onSelect,
|
||||
onSelectMultiple,
|
||||
supportAddCustomTool,
|
||||
scope = 'all',
|
||||
selectedTools,
|
||||
panelClassName,
|
||||
canChooseMCPTool,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
|
||||
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const invalidateCustomTools = useInvalidateAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const invalidateBuiltInTools = useInvalidateAllBuiltInTools()
|
||||
const invalidateWorkflowTools = useInvalidateAllWorkflowTools()
|
||||
const invalidateMcpTools = useInvalidateAllMCPTools()
|
||||
|
||||
const {
|
||||
plugins: featuredPlugins = [],
|
||||
isLoading: isFeaturedLoading,
|
||||
} = useFeaturedToolsRecommendations(enable_marketplace)
|
||||
|
||||
const { builtinToolList, customToolList, workflowToolList } = useMemo(() => {
|
||||
if (scope === 'plugins') {
|
||||
return {
|
||||
builtinToolList: buildInTools,
|
||||
customToolList: [],
|
||||
workflowToolList: [],
|
||||
}
|
||||
}
|
||||
if (scope === 'custom') {
|
||||
return {
|
||||
builtinToolList: [],
|
||||
customToolList: customTools,
|
||||
workflowToolList: [],
|
||||
}
|
||||
}
|
||||
if (scope === 'workflow') {
|
||||
return {
|
||||
builtinToolList: [],
|
||||
customToolList: [],
|
||||
workflowToolList: workflowTools,
|
||||
}
|
||||
}
|
||||
return {
|
||||
builtinToolList: buildInTools,
|
||||
customToolList: customTools,
|
||||
workflowToolList: workflowTools,
|
||||
}
|
||||
}, [scope, buildInTools, customTools, workflowTools])
|
||||
|
||||
const handleAddedCustomTool = invalidateCustomTools
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
if (disabled) return
|
||||
onShowChange(true)
|
||||
}
|
||||
|
||||
const handleSelect = (_type: BlockEnum, tool?: ToolDefaultValue) => {
|
||||
onSelect(tool!)
|
||||
}
|
||||
|
||||
const handleSelectMultiple = (_type: BlockEnum, tools: ToolDefaultValue[]) => {
|
||||
onSelectMultiple(tools)
|
||||
}
|
||||
|
||||
const [isShowEditCollectionToolModal, {
|
||||
setFalse: hideEditCustomCollectionModal,
|
||||
setTrue: showEditCustomCollectionModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
|
||||
await createCustomCollection(data)
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('common.api.actionSuccess'),
|
||||
})
|
||||
hideEditCustomCollectionModal()
|
||||
handleAddedCustomTool()
|
||||
}
|
||||
|
||||
if (isShowEditCollectionToolModal) {
|
||||
return (
|
||||
<EditCustomToolModal
|
||||
dialogClassName='bg-background-overlay'
|
||||
payload={null}
|
||||
onHide={hideEditCustomCollectionModal}
|
||||
onAdd={doCreateCustomToolCollection}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
open={isShow}
|
||||
onOpenChange={onShowChange}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
onClick={handleTriggerClick}
|
||||
>
|
||||
{trigger}
|
||||
</PortalToFollowElemTrigger>
|
||||
|
||||
<PortalToFollowElemContent className='z-[1000]'>
|
||||
<div className={cn('relative min-h-20 rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm', panelClassName)}>
|
||||
<div className='p-2 pb-1'>
|
||||
<SearchBox
|
||||
search={searchText}
|
||||
onSearchChange={setSearchText}
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder={t('plugin.searchTools')!}
|
||||
supportAddCustomTool={supportAddCustomTool}
|
||||
onAddedCustomTool={handleAddedCustomTool}
|
||||
onShowAddCustomCollectionModal={showEditCustomCollectionModal}
|
||||
inputClassName='grow'
|
||||
/>
|
||||
</div>
|
||||
<AllTools
|
||||
className='mt-1'
|
||||
toolContentClassName='max-w-[100%]'
|
||||
tags={tags}
|
||||
searchText={searchText}
|
||||
onSelect={handleSelect as OnSelectBlock}
|
||||
onSelectMultiple={handleSelectMultiple}
|
||||
buildInTools={builtinToolList || []}
|
||||
customTools={customToolList || []}
|
||||
workflowTools={workflowToolList || []}
|
||||
mcpTools={mcpTools || []}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
onTagsChange={setTags}
|
||||
featuredPlugins={featuredPlugins}
|
||||
featuredLoading={isFeaturedLoading}
|
||||
showFeatured={scope === 'all' && enable_marketplace}
|
||||
onFeaturedInstallSuccess={async () => {
|
||||
invalidateBuiltInTools()
|
||||
invalidateCustomTools()
|
||||
invalidateWorkflowTools()
|
||||
invalidateMcpTools()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ToolPicker)
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { ToolWithProvider } from '../../types'
|
||||
import { BlockEnum } from '../../types'
|
||||
import type { ToolDefaultValue } from '../types'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const normalizeProviderIcon = (icon: ToolWithProvider['icon']) => {
|
||||
if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`))
|
||||
return `${basePath}${icon}`
|
||||
return icon
|
||||
}
|
||||
|
||||
type Props = {
|
||||
provider: ToolWithProvider
|
||||
payload: Tool
|
||||
disabled?: boolean
|
||||
isAdded?: boolean
|
||||
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
|
||||
}
|
||||
|
||||
const ToolItem: FC<Props> = ({
|
||||
provider,
|
||||
payload,
|
||||
onSelect,
|
||||
disabled,
|
||||
isAdded,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const language = useGetLanguage()
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={payload.name}
|
||||
position='right'
|
||||
needsDelay={false}
|
||||
popupClassName='!p-0 !px-3 !py-2.5 !w-[200px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg'
|
||||
popupContent={(
|
||||
<div>
|
||||
<BlockIcon
|
||||
size='md'
|
||||
className='mb-2'
|
||||
type={BlockEnum.Tool}
|
||||
toolIcon={provider.icon}
|
||||
/>
|
||||
<div className='mb-1 text-sm leading-5 text-text-primary'>{payload.label[language]}</div>
|
||||
<div className='text-xs leading-[18px] text-text-secondary'>{payload.description[language]}</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key={payload.name}
|
||||
className='flex cursor-pointer items-center justify-between rounded-lg pl-[21px] pr-1 hover:bg-state-base-hover'
|
||||
onClick={() => {
|
||||
if (disabled) return
|
||||
const params: Record<string, string> = {}
|
||||
if (payload.parameters) {
|
||||
payload.parameters.forEach((item) => {
|
||||
params[item.name] = ''
|
||||
})
|
||||
}
|
||||
onSelect(BlockEnum.Tool, {
|
||||
provider_id: provider.id,
|
||||
provider_type: provider.type,
|
||||
provider_name: provider.name,
|
||||
plugin_id: provider.plugin_id,
|
||||
plugin_unique_identifier: provider.plugin_unique_identifier,
|
||||
provider_icon: normalizeProviderIcon(provider.icon),
|
||||
tool_name: payload.name,
|
||||
tool_label: payload.label[language],
|
||||
tool_description: payload.description[language],
|
||||
title: payload.label[language],
|
||||
is_team_authorization: provider.is_team_authorization,
|
||||
paramSchemas: payload.parameters,
|
||||
params,
|
||||
meta: provider.meta,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className={cn('system-sm-medium h-8 truncate border-l-2 border-divider-subtle pl-4 leading-8 text-text-secondary')}>
|
||||
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
|
||||
</div>
|
||||
{isAdded && (
|
||||
<div className='system-xs-regular mr-4 text-text-tertiary'>{t('tools.addToolModal.added')}</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip >
|
||||
)
|
||||
}
|
||||
export default React.memo(ToolItem)
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { ToolWithProvider } from '../../../types'
|
||||
import type { BlockEnum } from '../../../types'
|
||||
import type { ToolDefaultValue, ToolValue } from '../../types'
|
||||
import Tool from '../tool'
|
||||
import { ViewType } from '../../view-type-select'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
type Props = {
|
||||
payload: ToolWithProvider[]
|
||||
isShowLetterIndex: boolean
|
||||
indexBar: React.ReactNode
|
||||
hasSearchText: boolean
|
||||
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
|
||||
canNotSelectMultiple?: boolean
|
||||
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
|
||||
letters: string[]
|
||||
toolRefs: any
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
|
||||
const ToolViewFlatView: FC<Props> = ({
|
||||
letters,
|
||||
payload,
|
||||
isShowLetterIndex,
|
||||
indexBar,
|
||||
hasSearchText,
|
||||
onSelect,
|
||||
canNotSelectMultiple,
|
||||
onSelectMultiple,
|
||||
toolRefs,
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
}) => {
|
||||
const firstLetterToolIds = useMemo(() => {
|
||||
const res: Record<string, string> = {}
|
||||
letters.forEach((letter) => {
|
||||
const firstToolId = payload.find(tool => tool.letter === letter)?.id
|
||||
if (firstToolId)
|
||||
res[firstToolId] = letter
|
||||
})
|
||||
return res
|
||||
}, [payload, letters])
|
||||
return (
|
||||
<div className='flex w-full'>
|
||||
<div className='mr-1 grow'>
|
||||
{payload.map(tool => (
|
||||
<div
|
||||
key={tool.id}
|
||||
ref={(el) => {
|
||||
const letter = firstLetterToolIds[tool.id]
|
||||
if (letter)
|
||||
toolRefs.current[letter] = el
|
||||
}}
|
||||
>
|
||||
<Tool
|
||||
payload={tool}
|
||||
viewType={ViewType.flat}
|
||||
hasSearchText={hasSearchText}
|
||||
onSelect={onSelect}
|
||||
canNotSelectMultiple={canNotSelectMultiple}
|
||||
onSelectMultiple={onSelectMultiple}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{isShowLetterIndex && indexBar}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ToolViewFlatView)
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { ToolWithProvider } from '../../../types'
|
||||
import Tool from '../tool'
|
||||
import type { BlockEnum } from '../../../types'
|
||||
import { ViewType } from '../../view-type-select'
|
||||
import type { ToolDefaultValue, ToolValue } from '../../types'
|
||||
|
||||
type Props = {
|
||||
groupName: string
|
||||
toolList: ToolWithProvider[]
|
||||
hasSearchText: boolean
|
||||
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
|
||||
canNotSelectMultiple?: boolean
|
||||
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
|
||||
const Item: FC<Props> = ({
|
||||
groupName,
|
||||
toolList,
|
||||
hasSearchText,
|
||||
onSelect,
|
||||
canNotSelectMultiple,
|
||||
onSelectMultiple,
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div className='flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary'>
|
||||
{groupName}
|
||||
</div>
|
||||
<div>
|
||||
{toolList.map((tool: ToolWithProvider) => (
|
||||
<Tool
|
||||
key={tool.id}
|
||||
payload={tool}
|
||||
viewType={ViewType.tree}
|
||||
hasSearchText={hasSearchText}
|
||||
onSelect={onSelect}
|
||||
canNotSelectMultiple={canNotSelectMultiple}
|
||||
onSelectMultiple={onSelectMultiple}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Item)
|
||||
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import type { ToolWithProvider } from '../../../types'
|
||||
import type { BlockEnum } from '../../../types'
|
||||
import type { ToolDefaultValue, ToolValue } from '../../types'
|
||||
import Item from './item'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { AGENT_GROUP_NAME, CUSTOM_GROUP_NAME, WORKFLOW_GROUP_NAME } from '../../index-bar'
|
||||
|
||||
type Props = {
|
||||
payload: Record<string, ToolWithProvider[]>
|
||||
hasSearchText: boolean
|
||||
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
|
||||
canNotSelectMultiple?: boolean
|
||||
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
|
||||
const ToolListTreeView: FC<Props> = ({
|
||||
payload,
|
||||
hasSearchText,
|
||||
onSelect,
|
||||
canNotSelectMultiple,
|
||||
onSelectMultiple,
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const getI18nGroupName = useCallback((name: string) => {
|
||||
if (name === CUSTOM_GROUP_NAME)
|
||||
return t('workflow.tabs.customTool')
|
||||
|
||||
if (name === WORKFLOW_GROUP_NAME)
|
||||
return t('workflow.tabs.workflowTool')
|
||||
|
||||
if (name === AGENT_GROUP_NAME)
|
||||
return t('workflow.tabs.agent')
|
||||
|
||||
return name
|
||||
}, [t])
|
||||
|
||||
if (!payload) return null
|
||||
|
||||
return (
|
||||
<div>
|
||||
{Object.keys(payload).map(groupName => (
|
||||
<Item
|
||||
key={groupName}
|
||||
groupName={getI18nGroupName(groupName)}
|
||||
toolList={payload[groupName]}
|
||||
hasSearchText={hasSearchText}
|
||||
onSelect={onSelect}
|
||||
canNotSelectMultiple={canNotSelectMultiple}
|
||||
onSelectMultiple={onSelectMultiple}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ToolListTreeView)
|
||||
231
dify/web/app/components/workflow/block-selector/tool/tool.tsx
Normal file
231
dify/web/app/components/workflow/block-selector/tool/tool.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import type { Tool as ToolType } from '../../../tools/types'
|
||||
import { CollectionType } from '../../../tools/types'
|
||||
import type { ToolWithProvider } from '../../types'
|
||||
import { BlockEnum } from '../../types'
|
||||
import type { ToolDefaultValue, ToolValue } from '../types'
|
||||
import { ViewType } from '../view-type-select'
|
||||
import ActionItem from './action-item'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useHover } from 'ahooks'
|
||||
import McpToolNotSupportTooltip from '../../nodes/_base/components/mcp-tool-not-support-tooltip'
|
||||
import { Mcp } from '@/app/components/base/icons/src/vender/other'
|
||||
import { basePath } from '@/utils/var'
|
||||
|
||||
const normalizeProviderIcon = (icon: ToolWithProvider['icon']) => {
|
||||
if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`))
|
||||
return `${basePath}${icon}`
|
||||
return icon
|
||||
}
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
payload: ToolWithProvider
|
||||
viewType: ViewType
|
||||
hasSearchText: boolean
|
||||
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
|
||||
canNotSelectMultiple?: boolean
|
||||
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
isShowLetterIndex?: boolean
|
||||
}
|
||||
|
||||
const Tool: FC<Props> = ({
|
||||
className,
|
||||
payload,
|
||||
viewType,
|
||||
hasSearchText,
|
||||
onSelect,
|
||||
canNotSelectMultiple,
|
||||
onSelectMultiple,
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const isFlatView = viewType === ViewType.flat
|
||||
const notShowProvider = payload.type === CollectionType.workflow
|
||||
const actions = payload.tools
|
||||
const hasAction = !notShowProvider
|
||||
const [isFold, setFold] = React.useState<boolean>(true)
|
||||
const ref = useRef(null)
|
||||
const isHovering = useHover(ref)
|
||||
const isMCPTool = payload.type === CollectionType.mcp
|
||||
const isShowCanNotChooseMCPTip = !canChooseMCPTool && isMCPTool
|
||||
const getIsDisabled = useCallback((tool: ToolType) => {
|
||||
if (!selectedTools || !selectedTools.length) return false
|
||||
return selectedTools.some(selectedTool => (selectedTool.provider_name === payload.name || selectedTool.provider_name === payload.id) && selectedTool.tool_name === tool.name)
|
||||
}, [payload.id, payload.name, selectedTools])
|
||||
|
||||
const totalToolsNum = actions.length
|
||||
const selectedToolsNum = actions.filter(action => getIsDisabled(action)).length
|
||||
const isAllSelected = selectedToolsNum === totalToolsNum
|
||||
|
||||
const notShowProviderSelectInfo = useMemo(() => {
|
||||
if (isAllSelected) {
|
||||
return (
|
||||
<span className='system-xs-regular text-text-tertiary'>
|
||||
{t('tools.addToolModal.added')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
}, [isAllSelected, t])
|
||||
const selectedInfo = useMemo(() => {
|
||||
if (isHovering && !isAllSelected) {
|
||||
return (
|
||||
<span className='system-xs-regular text-components-button-secondary-accent-text'
|
||||
onClick={() => {
|
||||
onSelectMultiple?.(BlockEnum.Tool, actions.filter(action => !getIsDisabled(action)).map((tool) => {
|
||||
const params: Record<string, string> = {}
|
||||
if (tool.parameters) {
|
||||
tool.parameters.forEach((item) => {
|
||||
params[item.name] = ''
|
||||
})
|
||||
}
|
||||
return {
|
||||
provider_id: payload.id,
|
||||
provider_type: payload.type,
|
||||
provider_name: payload.name,
|
||||
plugin_id: payload.plugin_id,
|
||||
plugin_unique_identifier: payload.plugin_unique_identifier,
|
||||
provider_icon: normalizeProviderIcon(payload.icon),
|
||||
tool_name: tool.name,
|
||||
tool_label: tool.label[language],
|
||||
tool_description: tool.description[language],
|
||||
title: tool.label[language],
|
||||
is_team_authorization: payload.is_team_authorization,
|
||||
paramSchemas: tool.parameters,
|
||||
params,
|
||||
}
|
||||
}))
|
||||
}}
|
||||
>
|
||||
{t('workflow.tabs.addAll')}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
if (selectedToolsNum === 0)
|
||||
return <></>
|
||||
|
||||
return (
|
||||
<span className='system-xs-regular text-text-tertiary'>
|
||||
{isAllSelected
|
||||
? t('workflow.tabs.allAdded')
|
||||
: `${selectedToolsNum} / ${totalToolsNum}`
|
||||
}
|
||||
</span>
|
||||
)
|
||||
}, [actions, getIsDisabled, isAllSelected, isHovering, language, onSelectMultiple, payload.id, payload.is_team_authorization, payload.name, payload.type, selectedToolsNum, t, totalToolsNum])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSearchText && isFold) {
|
||||
setFold(false)
|
||||
return
|
||||
}
|
||||
if (!hasSearchText && !isFold)
|
||||
setFold(true)
|
||||
}, [hasSearchText])
|
||||
|
||||
const FoldIcon = isFold ? RiArrowRightSLine : RiArrowDownSLine
|
||||
|
||||
const groupName = useMemo(() => {
|
||||
if (payload.type === CollectionType.builtIn)
|
||||
return payload.author
|
||||
|
||||
if (payload.type === CollectionType.custom)
|
||||
return t('workflow.tabs.customTool')
|
||||
|
||||
if (payload.type === CollectionType.workflow)
|
||||
return t('workflow.tabs.workflowTool')
|
||||
|
||||
return ''
|
||||
}, [payload.author, payload.type, t])
|
||||
|
||||
return (
|
||||
<div
|
||||
key={payload.id}
|
||||
className={cn('mb-1 last-of-type:mb-0')}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={cn(className)}>
|
||||
<div
|
||||
className='group/item flex w-full cursor-pointer select-none items-center justify-between rounded-lg pl-3 pr-1 hover:bg-state-base-hover'
|
||||
onClick={() => {
|
||||
if (hasAction) {
|
||||
setFold(!isFold)
|
||||
return
|
||||
}
|
||||
|
||||
const tool = actions[0]
|
||||
const params: Record<string, string> = {}
|
||||
if (tool.parameters) {
|
||||
tool.parameters.forEach((item) => {
|
||||
params[item.name] = ''
|
||||
})
|
||||
}
|
||||
onSelect(BlockEnum.Tool, {
|
||||
provider_id: payload.id,
|
||||
provider_type: payload.type,
|
||||
provider_name: payload.name,
|
||||
plugin_id: payload.plugin_id,
|
||||
plugin_unique_identifier: payload.plugin_unique_identifier,
|
||||
provider_icon: normalizeProviderIcon(payload.icon),
|
||||
tool_name: tool.name,
|
||||
tool_label: tool.label[language],
|
||||
tool_description: tool.description[language],
|
||||
title: tool.label[language],
|
||||
is_team_authorization: payload.is_team_authorization,
|
||||
paramSchemas: tool.parameters,
|
||||
params,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className={cn('flex h-8 grow items-center', isShowCanNotChooseMCPTip && 'opacity-30')}>
|
||||
<BlockIcon
|
||||
className='shrink-0'
|
||||
type={BlockEnum.Tool}
|
||||
toolIcon={payload.icon}
|
||||
/>
|
||||
<div className='ml-2 flex w-0 grow items-center text-sm text-text-primary'>
|
||||
<span className='max-w-[250px] truncate'>{notShowProvider ? actions[0]?.label[language] : payload.label[language]}</span>
|
||||
{isFlatView && groupName && (
|
||||
<span className='system-xs-regular ml-2 shrink-0 text-text-quaternary'>{groupName}</span>
|
||||
)}
|
||||
{isMCPTool && <Mcp className='ml-2 size-3.5 shrink-0 text-text-quaternary' />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='ml-2 flex items-center'>
|
||||
{!isShowCanNotChooseMCPTip && !canNotSelectMultiple && (notShowProvider ? notShowProviderSelectInfo : selectedInfo)}
|
||||
{isShowCanNotChooseMCPTip && <McpToolNotSupportTooltip />}
|
||||
{hasAction && (
|
||||
<FoldIcon className={cn('h-4 w-4 shrink-0 text-text-tertiary group-hover/item:text-text-tertiary', isFold && 'text-text-quaternary')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!notShowProvider && hasAction && !isFold && (
|
||||
actions.map(action => (
|
||||
<ActionItem
|
||||
key={action.name}
|
||||
provider={payload}
|
||||
payload={action}
|
||||
onSelect={onSelect}
|
||||
disabled={getIsDisabled(action) || isShowCanNotChooseMCPTip}
|
||||
isAdded={getIsDisabled(action)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Tool)
|
||||
131
dify/web/app/components/workflow/block-selector/tools.tsx
Normal file
131
dify/web/app/components/workflow/block-selector/tools.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { memo, useMemo, useRef } from 'react'
|
||||
import type { BlockEnum, ToolWithProvider } from '../types'
|
||||
import IndexBar, { groupItems } from './index-bar'
|
||||
import type { ToolDefaultValue, ToolValue } from './types'
|
||||
import type { ToolTypeEnum } from './types'
|
||||
import { ViewType } from './view-type-select'
|
||||
import Empty from '@/app/components/tools/provider/empty'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import ToolListTreeView from './tool/tool-list-tree-view/list'
|
||||
import ToolListFlatView from './tool/tool-list-flat-view/list'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
type ToolsProps = {
|
||||
onSelect: (type: BlockEnum, tool: ToolDefaultValue) => void
|
||||
canNotSelectMultiple?: boolean
|
||||
onSelectMultiple?: (type: BlockEnum, tools: ToolDefaultValue[]) => void
|
||||
tools: ToolWithProvider[]
|
||||
viewType: ViewType
|
||||
hasSearchText: boolean
|
||||
toolType?: ToolTypeEnum
|
||||
isAgent?: boolean
|
||||
className?: string
|
||||
indexBarClassName?: string
|
||||
selectedTools?: ToolValue[]
|
||||
canChooseMCPTool?: boolean
|
||||
}
|
||||
const Tools = ({
|
||||
onSelect,
|
||||
canNotSelectMultiple,
|
||||
onSelectMultiple,
|
||||
tools,
|
||||
viewType,
|
||||
hasSearchText,
|
||||
toolType,
|
||||
isAgent,
|
||||
className,
|
||||
indexBarClassName,
|
||||
selectedTools,
|
||||
canChooseMCPTool,
|
||||
}: ToolsProps) => {
|
||||
// const tools: any = []
|
||||
const language = useGetLanguage()
|
||||
const isFlatView = viewType === ViewType.flat
|
||||
const isShowLetterIndex = isFlatView && tools.length > 10
|
||||
|
||||
/*
|
||||
treeViewToolsData:
|
||||
{
|
||||
A: {
|
||||
'google': [ // plugin organize name
|
||||
...tools
|
||||
],
|
||||
'custom': [ // custom tools
|
||||
...tools
|
||||
],
|
||||
'workflow': [ // workflow as tools
|
||||
...tools
|
||||
]
|
||||
}
|
||||
}
|
||||
*/
|
||||
const { letters, groups: withLetterAndGroupViewToolsData } = groupItems(tools, tool => tool.label[language][0])
|
||||
const treeViewToolsData = useMemo(() => {
|
||||
const result: Record<string, ToolWithProvider[]> = {}
|
||||
Object.keys(withLetterAndGroupViewToolsData).forEach((letter) => {
|
||||
Object.keys(withLetterAndGroupViewToolsData[letter]).forEach((groupName) => {
|
||||
if (!result[groupName])
|
||||
result[groupName] = []
|
||||
result[groupName].push(...withLetterAndGroupViewToolsData[letter][groupName])
|
||||
})
|
||||
})
|
||||
return result
|
||||
}, [withLetterAndGroupViewToolsData])
|
||||
|
||||
const listViewToolData = useMemo(() => {
|
||||
const result: ToolWithProvider[] = []
|
||||
letters.forEach((letter) => {
|
||||
Object.keys(withLetterAndGroupViewToolsData[letter]).forEach((groupName) => {
|
||||
result.push(...withLetterAndGroupViewToolsData[letter][groupName].map((item) => {
|
||||
return {
|
||||
...item,
|
||||
letter,
|
||||
}
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
return result
|
||||
}, [withLetterAndGroupViewToolsData, letters])
|
||||
|
||||
const toolRefs = useRef({})
|
||||
|
||||
return (
|
||||
<div className={classNames('max-w-[100%] p-1', className)}>
|
||||
{!tools.length && !hasSearchText && (
|
||||
<div className='py-10'>
|
||||
<Empty type={toolType!} isAgent={isAgent} />
|
||||
</div>
|
||||
)}
|
||||
{!!tools.length && (
|
||||
isFlatView ? (
|
||||
<ToolListFlatView
|
||||
toolRefs={toolRefs}
|
||||
letters={letters}
|
||||
payload={listViewToolData}
|
||||
isShowLetterIndex={isShowLetterIndex}
|
||||
hasSearchText={hasSearchText}
|
||||
onSelect={onSelect}
|
||||
canNotSelectMultiple={canNotSelectMultiple}
|
||||
onSelectMultiple={onSelectMultiple}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
indexBar={<IndexBar letters={letters} itemRefs={toolRefs} className={indexBarClassName} />}
|
||||
/>
|
||||
) : (
|
||||
<ToolListTreeView
|
||||
payload={treeViewToolsData}
|
||||
hasSearchText={hasSearchText}
|
||||
onSelect={onSelect}
|
||||
canNotSelectMultiple={canNotSelectMultiple}
|
||||
onSelectMultiple={onSelectMultiple}
|
||||
selectedTools={selectedTools}
|
||||
canChooseMCPTool={canChooseMCPTool}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Tools)
|
||||
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import type { TriggerWithProvider } from '../types'
|
||||
import type { Event } from '@/app/components/tools/types'
|
||||
import { BlockEnum } from '../../types'
|
||||
import type { TriggerDefaultValue } from '../types'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import BlockIcon from '../../block-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Props = {
|
||||
provider: TriggerWithProvider
|
||||
payload: Event
|
||||
disabled?: boolean
|
||||
isAdded?: boolean
|
||||
onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void
|
||||
}
|
||||
|
||||
const TriggerPluginActionItem: FC<Props> = ({
|
||||
provider,
|
||||
payload,
|
||||
onSelect,
|
||||
disabled,
|
||||
isAdded,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
key={payload.name}
|
||||
position='right'
|
||||
needsDelay={false}
|
||||
popupClassName='!p-0 !px-3 !py-2.5 !w-[224px] !leading-[18px] !text-xs !text-gray-700 !border-[0.5px] !border-black/5 !rounded-xl !shadow-lg'
|
||||
popupContent={(
|
||||
<div>
|
||||
<BlockIcon
|
||||
size='md'
|
||||
className='mb-2'
|
||||
type={BlockEnum.TriggerPlugin}
|
||||
toolIcon={provider.icon}
|
||||
/>
|
||||
<div className='mb-1 text-sm leading-5 text-text-primary'>{payload.label[language]}</div>
|
||||
<div className='text-xs leading-[18px] text-text-secondary'>{payload.description[language]}</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div
|
||||
key={payload.name}
|
||||
className='flex cursor-pointer items-center justify-between rounded-lg pl-[21px] pr-1 hover:bg-state-base-hover'
|
||||
onClick={() => {
|
||||
if (disabled) return
|
||||
const params: Record<string, string> = {}
|
||||
if (payload.parameters) {
|
||||
payload.parameters.forEach((item: any) => {
|
||||
params[item.name] = ''
|
||||
})
|
||||
}
|
||||
onSelect(BlockEnum.TriggerPlugin, {
|
||||
plugin_id: provider.plugin_id,
|
||||
provider_id: provider.name,
|
||||
provider_type: provider.type as string,
|
||||
provider_name: provider.name,
|
||||
event_name: payload.name,
|
||||
event_label: payload.label[language],
|
||||
event_description: payload.description[language],
|
||||
plugin_unique_identifier: provider.plugin_unique_identifier,
|
||||
title: payload.label[language],
|
||||
is_team_authorization: provider.is_team_authorization,
|
||||
output_schema: payload.output_schema || {},
|
||||
paramSchemas: payload.parameters,
|
||||
params,
|
||||
meta: provider.meta,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className={cn('system-sm-medium h-8 truncate border-l-2 border-divider-subtle pl-4 leading-8 text-text-secondary')}>
|
||||
<span className={cn(disabled && 'opacity-30')}>{payload.label[language]}</span>
|
||||
</div>
|
||||
{isAdded && (
|
||||
<div className='system-xs-regular mr-4 text-text-tertiary'>{t('tools.addToolModal.added')}</div>
|
||||
)}
|
||||
</div>
|
||||
</Tooltip >
|
||||
)
|
||||
}
|
||||
export default React.memo(TriggerPluginActionItem)
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useMemo, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import type { TriggerDefaultValue, TriggerWithProvider } from '@/app/components/workflow/block-selector/types'
|
||||
import TriggerPluginActionItem from './action-item'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
payload: TriggerWithProvider
|
||||
hasSearchText: boolean
|
||||
onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void
|
||||
}
|
||||
|
||||
const TriggerPluginItem: FC<Props> = ({
|
||||
className,
|
||||
payload,
|
||||
hasSearchText,
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const notShowProvider = payload.type === CollectionType.workflow
|
||||
const actions = payload.events
|
||||
const hasAction = !notShowProvider
|
||||
const [isFold, setFold] = React.useState<boolean>(true)
|
||||
const ref = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSearchText && isFold) {
|
||||
setFold(false)
|
||||
return
|
||||
}
|
||||
if (!hasSearchText && !isFold)
|
||||
setFold(true)
|
||||
}, [hasSearchText])
|
||||
|
||||
const FoldIcon = isFold ? RiArrowRightSLine : RiArrowDownSLine
|
||||
|
||||
const groupName = useMemo(() => {
|
||||
if (payload.type === CollectionType.builtIn)
|
||||
return payload.author
|
||||
|
||||
if (payload.type === CollectionType.custom)
|
||||
return t('workflow.tabs.customTool')
|
||||
|
||||
if (payload.type === CollectionType.workflow)
|
||||
return t('workflow.tabs.workflowTool')
|
||||
|
||||
return payload.author || ''
|
||||
}, [payload.author, payload.type, t])
|
||||
|
||||
return (
|
||||
<div
|
||||
key={payload.id}
|
||||
className={cn('mb-1 last-of-type:mb-0')}
|
||||
ref={ref}
|
||||
>
|
||||
<div className={cn(className)}>
|
||||
<div
|
||||
className='group/item flex w-full cursor-pointer select-none items-center justify-between rounded-lg pl-3 pr-1 hover:bg-state-base-hover'
|
||||
onClick={() => {
|
||||
if (hasAction) {
|
||||
setFold(!isFold)
|
||||
return
|
||||
}
|
||||
|
||||
const event = actions[0]
|
||||
const params: Record<string, string> = {}
|
||||
if (event.parameters) {
|
||||
event.parameters.forEach((item: any) => {
|
||||
params[item.name] = ''
|
||||
})
|
||||
}
|
||||
onSelect(BlockEnum.TriggerPlugin, {
|
||||
plugin_id: payload.plugin_id,
|
||||
provider_id: payload.name,
|
||||
provider_type: payload.type,
|
||||
provider_name: payload.name,
|
||||
event_name: event.name,
|
||||
event_label: event.label[language],
|
||||
event_description: event.description[language],
|
||||
title: event.label[language],
|
||||
plugin_unique_identifier: payload.plugin_unique_identifier,
|
||||
is_team_authorization: payload.is_team_authorization,
|
||||
output_schema: event.output_schema || {},
|
||||
paramSchemas: event.parameters,
|
||||
params,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className='flex h-8 grow items-center'>
|
||||
<BlockIcon
|
||||
className='shrink-0'
|
||||
type={BlockEnum.TriggerPlugin}
|
||||
toolIcon={payload.icon}
|
||||
/>
|
||||
<div className='ml-2 flex min-w-0 flex-1 items-center text-sm text-text-primary'>
|
||||
<span className='max-w-[200px] truncate'>{notShowProvider ? actions[0]?.label[language] : payload.label[language]}</span>
|
||||
<span className='system-xs-regular ml-2 truncate text-text-quaternary'>{groupName}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='ml-2 flex items-center'>
|
||||
{hasAction && (
|
||||
<FoldIcon className={cn('h-4 w-4 shrink-0 text-text-tertiary group-hover/item:text-text-tertiary', isFold && 'text-text-quaternary')} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!notShowProvider && hasAction && !isFold && (
|
||||
actions.map(action => (
|
||||
<TriggerPluginActionItem
|
||||
key={action.name}
|
||||
provider={payload}
|
||||
payload={action}
|
||||
onSelect={onSelect}
|
||||
disabled={false}
|
||||
isAdded={false}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(TriggerPluginItem)
|
||||
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
import { memo, useEffect, useMemo } from 'react'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
import TriggerPluginItem from './item'
|
||||
import type { BlockEnum } from '../../types'
|
||||
import type { TriggerDefaultValue, TriggerWithProvider } from '../types'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
|
||||
type TriggerPluginListProps = {
|
||||
onSelect: (type: BlockEnum, trigger?: TriggerDefaultValue) => void
|
||||
searchText: string
|
||||
onContentStateChange?: (hasContent: boolean) => void
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
const TriggerPluginList = ({
|
||||
onSelect,
|
||||
searchText,
|
||||
onContentStateChange,
|
||||
}: TriggerPluginListProps) => {
|
||||
const { data: triggerPluginsData } = useAllTriggerPlugins()
|
||||
const language = useGetLanguage()
|
||||
|
||||
const normalizedSearch = searchText.trim().toLowerCase()
|
||||
const triggerPlugins = useMemo(() => {
|
||||
const plugins = triggerPluginsData || []
|
||||
const getLocalizedText = (text?: Record<string, string> | null) => {
|
||||
if (!text)
|
||||
return ''
|
||||
|
||||
if (text[language])
|
||||
return text[language]
|
||||
|
||||
if (text['en-US'])
|
||||
return text['en-US']
|
||||
|
||||
const firstValue = Object.values(text).find(Boolean)
|
||||
return (typeof firstValue === 'string') ? firstValue : ''
|
||||
}
|
||||
const getSearchableTexts = (name: string, label?: Record<string, string> | null) => {
|
||||
const localized = getLocalizedText(label)
|
||||
const values = [localized, name].filter(Boolean)
|
||||
return values.length > 0 ? values : ['']
|
||||
}
|
||||
const isMatchingKeywords = (value: string) => value.toLowerCase().includes(normalizedSearch)
|
||||
|
||||
if (!normalizedSearch)
|
||||
return plugins.filter(triggerWithProvider => triggerWithProvider.events.length > 0)
|
||||
|
||||
return plugins.reduce<TriggerWithProvider[]>((acc, triggerWithProvider) => {
|
||||
if (triggerWithProvider.events.length === 0)
|
||||
return acc
|
||||
|
||||
const providerMatches = getSearchableTexts(
|
||||
triggerWithProvider.name,
|
||||
triggerWithProvider.label,
|
||||
).some(text => isMatchingKeywords(text))
|
||||
|
||||
if (providerMatches) {
|
||||
acc.push(triggerWithProvider)
|
||||
return acc
|
||||
}
|
||||
|
||||
const matchedEvents = triggerWithProvider.events.filter((event) => {
|
||||
return getSearchableTexts(
|
||||
event.name,
|
||||
event.label,
|
||||
).some(text => isMatchingKeywords(text))
|
||||
})
|
||||
|
||||
if (matchedEvents.length > 0) {
|
||||
acc.push({
|
||||
...triggerWithProvider,
|
||||
events: matchedEvents,
|
||||
})
|
||||
}
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
}, [triggerPluginsData, normalizedSearch, language])
|
||||
|
||||
const hasContent = triggerPlugins.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
onContentStateChange?.(hasContent)
|
||||
}, [hasContent, onContentStateChange])
|
||||
|
||||
if (!hasContent)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="p-1">
|
||||
{triggerPlugins.map(plugin => (
|
||||
<TriggerPluginItem
|
||||
key={plugin.id}
|
||||
payload={plugin}
|
||||
onSelect={onSelect}
|
||||
hasSearchText={!!searchText}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(TriggerPluginList)
|
||||
338
dify/web/app/components/workflow/block-selector/types.ts
Normal file
338
dify/web/app/components/workflow/block-selector/types.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { ParametersSchema, PluginMeta, PluginTriggerSubscriptionConstructor, SupportedCreationMethods, TriggerEvent } from '../../plugins/types'
|
||||
import type { Collection, Event } from '../../tools/types'
|
||||
|
||||
export enum TabsEnum {
|
||||
Start = 'start',
|
||||
Blocks = 'blocks',
|
||||
Tools = 'tools',
|
||||
Sources = 'sources',
|
||||
}
|
||||
|
||||
export enum ToolTypeEnum {
|
||||
All = 'all',
|
||||
BuiltIn = 'built-in',
|
||||
Custom = 'custom',
|
||||
Workflow = 'workflow',
|
||||
MCP = 'mcp',
|
||||
}
|
||||
|
||||
export enum BlockClassificationEnum {
|
||||
Default = '-',
|
||||
QuestionUnderstand = 'question-understand',
|
||||
Logic = 'logic',
|
||||
Transform = 'transform',
|
||||
Utilities = 'utilities',
|
||||
}
|
||||
|
||||
type PluginCommonDefaultValue = {
|
||||
provider_id: string
|
||||
provider_type: string
|
||||
provider_name: string
|
||||
}
|
||||
|
||||
export type TriggerDefaultValue = PluginCommonDefaultValue & {
|
||||
plugin_id?: string
|
||||
event_name: string
|
||||
event_label: string
|
||||
event_description: string
|
||||
title: string
|
||||
plugin_unique_identifier: string
|
||||
is_team_authorization: boolean
|
||||
params: Record<string, any>
|
||||
paramSchemas: Record<string, any>[]
|
||||
output_schema: Record<string, any>
|
||||
subscription_id?: string
|
||||
meta?: PluginMeta
|
||||
}
|
||||
|
||||
export type ToolDefaultValue = PluginCommonDefaultValue & {
|
||||
tool_name: string
|
||||
tool_label: string
|
||||
tool_description: string
|
||||
title: string
|
||||
is_team_authorization: boolean
|
||||
params: Record<string, any>
|
||||
paramSchemas: Record<string, any>[]
|
||||
output_schema?: Record<string, any>
|
||||
credential_id?: string
|
||||
meta?: PluginMeta
|
||||
plugin_id?: string
|
||||
provider_icon?: Collection['icon']
|
||||
plugin_unique_identifier?: string
|
||||
}
|
||||
|
||||
export type DataSourceDefaultValue = Omit<PluginCommonDefaultValue, 'provider_id'> & {
|
||||
plugin_id: string
|
||||
provider_type: string
|
||||
provider_name: string
|
||||
datasource_name: string
|
||||
datasource_label: string
|
||||
title: string
|
||||
fileExtensions?: string[]
|
||||
plugin_unique_identifier?: string
|
||||
}
|
||||
|
||||
export type PluginDefaultValue = ToolDefaultValue | DataSourceDefaultValue | TriggerDefaultValue
|
||||
|
||||
export type ToolValue = {
|
||||
provider_name: string
|
||||
provider_show_name?: string
|
||||
tool_name: string
|
||||
tool_label: string
|
||||
tool_description?: string
|
||||
settings?: Record<string, any>
|
||||
parameters?: Record<string, any>
|
||||
enabled?: boolean
|
||||
extra?: Record<string, any>
|
||||
credential_id?: string
|
||||
}
|
||||
|
||||
export type DataSourceItem = {
|
||||
plugin_id: string
|
||||
plugin_unique_identifier: string
|
||||
provider: string
|
||||
declaration: {
|
||||
credentials_schema: any[]
|
||||
provider_type: string
|
||||
identity: {
|
||||
author: string
|
||||
description: TypeWithI18N
|
||||
icon: string | { background: string; content: string }
|
||||
label: TypeWithI18N
|
||||
name: string
|
||||
tags: string[]
|
||||
}
|
||||
datasources: {
|
||||
description: TypeWithI18N
|
||||
identity: {
|
||||
author: string
|
||||
icon?: string | { background: string; content: string }
|
||||
label: TypeWithI18N
|
||||
name: string
|
||||
provider: string
|
||||
}
|
||||
parameters: any[]
|
||||
output_schema?: {
|
||||
type: string
|
||||
properties: Record<string, any>
|
||||
}
|
||||
}[]
|
||||
}
|
||||
is_authorized: boolean
|
||||
}
|
||||
|
||||
// Backend API types - exact match with Python definitions
|
||||
export type TriggerParameter = {
|
||||
multiple: boolean
|
||||
name: string
|
||||
label: TypeWithI18N
|
||||
description?: TypeWithI18N
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'file' | 'files'
|
||||
| 'model-selector' | 'app-selector' | 'object' | 'array' | 'dynamic-select'
|
||||
auto_generate?: {
|
||||
type: string
|
||||
value?: any
|
||||
} | null
|
||||
template?: {
|
||||
type: string
|
||||
value?: any
|
||||
} | null
|
||||
scope?: string | null
|
||||
required?: boolean
|
||||
default?: any
|
||||
min?: number | null
|
||||
max?: number | null
|
||||
precision?: number | null
|
||||
options?: Array<{
|
||||
value: string
|
||||
label: TypeWithI18N
|
||||
icon?: string | null
|
||||
}> | null
|
||||
}
|
||||
|
||||
export type TriggerCredentialField = {
|
||||
type: 'secret-input' | 'text-input' | 'select' | 'boolean'
|
||||
| 'app-selector' | 'model-selector' | 'tools-selector'
|
||||
name: string
|
||||
scope?: string | null
|
||||
required: boolean
|
||||
default?: string | number | boolean | Array<any> | null
|
||||
options?: Array<{
|
||||
value: string
|
||||
label: TypeWithI18N
|
||||
}> | null
|
||||
label: TypeWithI18N
|
||||
help?: TypeWithI18N
|
||||
url?: string | null
|
||||
placeholder?: TypeWithI18N
|
||||
}
|
||||
|
||||
export type TriggerSubscriptionSchema = {
|
||||
parameters_schema: TriggerParameter[]
|
||||
properties_schema: TriggerCredentialField[]
|
||||
}
|
||||
|
||||
export type TriggerIdentity = {
|
||||
author: string
|
||||
name: string
|
||||
label: TypeWithI18N
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type TriggerDescription = {
|
||||
human: TypeWithI18N
|
||||
llm: TypeWithI18N
|
||||
}
|
||||
|
||||
export type TriggerApiEntity = {
|
||||
name: string
|
||||
identity: TriggerIdentity
|
||||
description: TypeWithI18N
|
||||
parameters: TriggerParameter[]
|
||||
output_schema?: Record<string, any>
|
||||
}
|
||||
|
||||
export type TriggerProviderApiEntity = {
|
||||
author: string
|
||||
name: string
|
||||
label: TypeWithI18N
|
||||
description: TypeWithI18N
|
||||
icon?: string
|
||||
icon_dark?: string
|
||||
tags: string[]
|
||||
plugin_id?: string
|
||||
plugin_unique_identifier: string
|
||||
supported_creation_methods: SupportedCreationMethods[]
|
||||
credentials_schema?: TriggerCredentialField[]
|
||||
subscription_constructor?: PluginTriggerSubscriptionConstructor | null
|
||||
subscription_schema: ParametersSchema[]
|
||||
events: TriggerEvent[]
|
||||
}
|
||||
|
||||
// Frontend types - compatible with ToolWithProvider
|
||||
export type TriggerWithProvider = Collection & {
|
||||
events: Event[]
|
||||
meta: PluginMeta
|
||||
plugin_unique_identifier: string
|
||||
credentials_schema?: TriggerCredentialField[]
|
||||
subscription_constructor?: PluginTriggerSubscriptionConstructor | null
|
||||
subscription_schema?: ParametersSchema[]
|
||||
supported_creation_methods: SupportedCreationMethods[]
|
||||
}
|
||||
|
||||
// ===== API Service Types =====
|
||||
|
||||
// Trigger subscription instance types
|
||||
|
||||
export enum TriggerCredentialTypeEnum {
|
||||
ApiKey = 'api-key',
|
||||
Oauth2 = 'oauth2',
|
||||
Unauthorized = 'unauthorized',
|
||||
}
|
||||
|
||||
type TriggerSubscriptionStructure = {
|
||||
id: string
|
||||
name: string
|
||||
provider: string
|
||||
credential_type: TriggerCredentialTypeEnum
|
||||
credentials: TriggerSubCredentials
|
||||
endpoint: string
|
||||
parameters: TriggerSubParameters
|
||||
properties: TriggerSubProperties
|
||||
workflows_in_use: number
|
||||
}
|
||||
|
||||
export type TriggerSubscription = TriggerSubscriptionStructure
|
||||
|
||||
export type TriggerSubCredentials = {
|
||||
access_tokens: string
|
||||
}
|
||||
|
||||
export type TriggerSubParameters = {
|
||||
repository: string
|
||||
webhook_secret?: string
|
||||
}
|
||||
|
||||
export type TriggerSubProperties = {
|
||||
active: boolean
|
||||
events: string[]
|
||||
external_id: string
|
||||
repository: string
|
||||
webhook_secret?: string
|
||||
}
|
||||
|
||||
export type TriggerSubscriptionBuilder = TriggerSubscriptionStructure
|
||||
|
||||
// OAuth configuration types
|
||||
export type TriggerOAuthConfig = {
|
||||
configured: boolean
|
||||
custom_configured: boolean
|
||||
custom_enabled: boolean
|
||||
redirect_uri: string
|
||||
oauth_client_schema: ParametersSchema[]
|
||||
params: {
|
||||
client_id: string
|
||||
client_secret: string
|
||||
[key: string]: any
|
||||
}
|
||||
system_configured: boolean
|
||||
}
|
||||
|
||||
export type TriggerOAuthClientParams = {
|
||||
client_id: string
|
||||
client_secret: string
|
||||
authorization_url?: string
|
||||
token_url?: string
|
||||
scope?: string
|
||||
}
|
||||
|
||||
export type TriggerOAuthResponse = {
|
||||
authorization_url: string
|
||||
subscription_builder: TriggerSubscriptionBuilder
|
||||
}
|
||||
|
||||
export type TriggerLogEntity = {
|
||||
id: string
|
||||
endpoint: string
|
||||
request: LogRequest
|
||||
response: LogResponse
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type LogRequest = {
|
||||
method: string
|
||||
url: string
|
||||
headers: LogRequestHeaders
|
||||
data: string
|
||||
}
|
||||
|
||||
export type LogRequestHeaders = {
|
||||
'Host': string
|
||||
'User-Agent': string
|
||||
'Content-Length': string
|
||||
'Accept': string
|
||||
'Content-Type': string
|
||||
'X-Forwarded-For': string
|
||||
'X-Forwarded-Host': string
|
||||
'X-Forwarded-Proto': string
|
||||
'X-Github-Delivery': string
|
||||
'X-Github-Event': string
|
||||
'X-Github-Hook-Id': string
|
||||
'X-Github-Hook-Installation-Target-Id': string
|
||||
'X-Github-Hook-Installation-Target-Type': string
|
||||
'Accept-Encoding': string
|
||||
[key: string]: string
|
||||
}
|
||||
|
||||
export type LogResponse = {
|
||||
status_code: number
|
||||
headers: LogResponseHeaders
|
||||
data: string
|
||||
}
|
||||
|
||||
export type LogResponseHeaders = {
|
||||
'Content-Type': string
|
||||
'Content-Length': string
|
||||
[key: string]: string
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const useCheckVerticalScrollbar = (ref: React.RefObject<HTMLElement>) => {
|
||||
const [hasVerticalScrollbar, setHasVerticalScrollbar] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const elem = ref.current
|
||||
if (!elem) return
|
||||
|
||||
const checkScrollbar = () => {
|
||||
setHasVerticalScrollbar(elem.scrollHeight > elem.clientHeight)
|
||||
}
|
||||
|
||||
checkScrollbar()
|
||||
|
||||
const resizeObserver = new ResizeObserver(checkScrollbar)
|
||||
resizeObserver.observe(elem)
|
||||
|
||||
const mutationObserver = new MutationObserver(checkScrollbar)
|
||||
mutationObserver.observe(elem, { childList: true, subtree: true, characterData: true })
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
mutationObserver.disconnect()
|
||||
}
|
||||
}, [ref])
|
||||
|
||||
return hasVerticalScrollbar
|
||||
}
|
||||
|
||||
export default useCheckVerticalScrollbar
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react'
|
||||
import { useThrottleFn } from 'ahooks'
|
||||
|
||||
export enum ScrollPosition {
|
||||
belowTheWrap = 'belowTheWrap',
|
||||
showing = 'showing',
|
||||
aboveTheWrap = 'aboveTheWrap',
|
||||
}
|
||||
|
||||
type Params = {
|
||||
wrapElemRef: React.RefObject<HTMLElement | null>
|
||||
nextToStickyELemRef: React.RefObject<HTMLElement | null>
|
||||
}
|
||||
const useStickyScroll = ({
|
||||
wrapElemRef,
|
||||
nextToStickyELemRef,
|
||||
}: Params) => {
|
||||
const [scrollPosition, setScrollPosition] = React.useState<ScrollPosition>(ScrollPosition.belowTheWrap)
|
||||
const { run: handleScroll } = useThrottleFn(() => {
|
||||
const wrapDom = wrapElemRef.current
|
||||
const stickyDOM = nextToStickyELemRef.current
|
||||
if (!wrapDom || !stickyDOM)
|
||||
return
|
||||
const { height: wrapHeight, top: wrapTop } = wrapDom.getBoundingClientRect()
|
||||
const { top: nextToStickyTop } = stickyDOM.getBoundingClientRect()
|
||||
let scrollPositionNew: ScrollPosition
|
||||
|
||||
if (nextToStickyTop - wrapTop >= wrapHeight)
|
||||
scrollPositionNew = ScrollPosition.belowTheWrap
|
||||
else if (nextToStickyTop <= wrapTop)
|
||||
scrollPositionNew = ScrollPosition.aboveTheWrap
|
||||
else
|
||||
scrollPositionNew = ScrollPosition.showing
|
||||
|
||||
if (scrollPosition !== scrollPositionNew)
|
||||
setScrollPosition(scrollPositionNew)
|
||||
}, { wait: 100 })
|
||||
|
||||
return {
|
||||
handleScroll,
|
||||
scrollPosition,
|
||||
}
|
||||
}
|
||||
|
||||
export default useStickyScroll
|
||||
37
dify/web/app/components/workflow/block-selector/utils.ts
Normal file
37
dify/web/app/components/workflow/block-selector/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Tool } from '@/app/components/tools/types'
|
||||
import type { DataSourceItem } from './types'
|
||||
|
||||
export const transformDataSourceToTool = (dataSourceItem: DataSourceItem) => {
|
||||
return {
|
||||
id: dataSourceItem.plugin_id,
|
||||
provider: dataSourceItem.provider,
|
||||
name: dataSourceItem.provider,
|
||||
author: dataSourceItem.declaration.identity.author,
|
||||
description: dataSourceItem.declaration.identity.description,
|
||||
icon: dataSourceItem.declaration.identity.icon,
|
||||
label: dataSourceItem.declaration.identity.label,
|
||||
type: dataSourceItem.declaration.provider_type,
|
||||
team_credentials: {},
|
||||
allow_delete: true,
|
||||
is_team_authorization: dataSourceItem.is_authorized,
|
||||
is_authorized: dataSourceItem.is_authorized,
|
||||
labels: dataSourceItem.declaration.identity.tags || [],
|
||||
plugin_id: dataSourceItem.plugin_id,
|
||||
plugin_unique_identifier: dataSourceItem.plugin_unique_identifier,
|
||||
tools: dataSourceItem.declaration.datasources.map((datasource) => {
|
||||
return {
|
||||
name: datasource.identity.name,
|
||||
author: datasource.identity.author,
|
||||
label: datasource.identity.label,
|
||||
description: datasource.description,
|
||||
parameters: datasource.parameters,
|
||||
labels: [],
|
||||
output_schema: datasource.output_schema,
|
||||
} as Tool
|
||||
}),
|
||||
credentialsSchema: dataSourceItem.declaration.credentials_schema || [],
|
||||
meta: {
|
||||
version: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { RiNodeTree, RiSortAlphabetAsc } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export enum ViewType {
|
||||
flat = 'flat',
|
||||
tree = 'tree',
|
||||
}
|
||||
|
||||
type Props = {
|
||||
viewType: ViewType
|
||||
onChange: (viewType: ViewType) => void
|
||||
}
|
||||
|
||||
const ViewTypeSelect: FC<Props> = ({
|
||||
viewType,
|
||||
onChange,
|
||||
}) => {
|
||||
const handleChange = useCallback((nextViewType: ViewType) => {
|
||||
return () => {
|
||||
if (nextViewType === viewType)
|
||||
return
|
||||
onChange(nextViewType)
|
||||
}
|
||||
}, [viewType, onChange])
|
||||
|
||||
return (
|
||||
<div className='flex items-center rounded-lg bg-components-segmented-control-bg-normal p-px'>
|
||||
<div
|
||||
className={
|
||||
cn('rounded-lg p-[3px]',
|
||||
viewType === ViewType.flat
|
||||
? 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-xs'
|
||||
: 'cursor-pointer text-text-tertiary',
|
||||
)
|
||||
}
|
||||
onClick={handleChange(ViewType.flat)}
|
||||
>
|
||||
<RiSortAlphabetAsc className='h-4 w-4' />
|
||||
</div>
|
||||
<div
|
||||
className={
|
||||
cn('rounded-lg p-[3px]',
|
||||
viewType === ViewType.tree
|
||||
? 'bg-components-segmented-control-item-active-bg text-text-accent-light-mode-only shadow-xs'
|
||||
: 'cursor-pointer text-text-tertiary',
|
||||
)
|
||||
}
|
||||
onClick={handleChange(ViewType.tree)}
|
||||
>
|
||||
<RiNodeTree className='h-4 w-4 ' />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ViewTypeSelect)
|
||||
119
dify/web/app/components/workflow/candidate-node.tsx
Normal file
119
dify/web/app/components/workflow/candidate-node.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import {
|
||||
memo,
|
||||
} from 'react'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
useViewport,
|
||||
} from 'reactflow'
|
||||
import { useEventListener } from 'ahooks'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from './store'
|
||||
import { WorkflowHistoryEvent, useAutoGenerateWebhookUrl, useNodesInteractions, useNodesSyncDraft, useWorkflowHistory } from './hooks'
|
||||
import { CUSTOM_NODE } from './constants'
|
||||
import { getIterationStartNode, getLoopStartNode } from './utils'
|
||||
import CustomNode from './nodes'
|
||||
import CustomNoteNode from './note-node'
|
||||
import { CUSTOM_NOTE_NODE } from './note-node/constants'
|
||||
import { BlockEnum } from './types'
|
||||
|
||||
const CandidateNode = () => {
|
||||
const store = useStoreApi()
|
||||
const reactflow = useReactFlow()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const candidateNode = useStore(s => s.candidateNode)
|
||||
const mousePosition = useStore(s => s.mousePosition)
|
||||
const { zoom } = useViewport()
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const autoGenerateWebhookUrl = useAutoGenerateWebhookUrl()
|
||||
|
||||
useEventListener('click', (e) => {
|
||||
const { candidateNode, mousePosition } = workflowStore.getState()
|
||||
|
||||
if (candidateNode) {
|
||||
e.preventDefault()
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const { screenToFlowPosition } = reactflow
|
||||
const nodes = getNodes()
|
||||
const { x, y } = screenToFlowPosition({ x: mousePosition.pageX, y: mousePosition.pageY })
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.push({
|
||||
...candidateNode,
|
||||
data: {
|
||||
...candidateNode.data,
|
||||
_isCandidate: false,
|
||||
},
|
||||
position: {
|
||||
x,
|
||||
y,
|
||||
},
|
||||
})
|
||||
if (candidateNode.data.type === BlockEnum.Iteration)
|
||||
draft.push(getIterationStartNode(candidateNode.id))
|
||||
|
||||
if (candidateNode.data.type === BlockEnum.Loop)
|
||||
draft.push(getLoopStartNode(candidateNode.id))
|
||||
})
|
||||
setNodes(newNodes)
|
||||
if (candidateNode.type === CUSTOM_NOTE_NODE)
|
||||
saveStateToHistory(WorkflowHistoryEvent.NoteAdd, { nodeId: candidateNode.id })
|
||||
else
|
||||
saveStateToHistory(WorkflowHistoryEvent.NodeAdd, { nodeId: candidateNode.id })
|
||||
|
||||
workflowStore.setState({ candidateNode: undefined })
|
||||
|
||||
if (candidateNode.type === CUSTOM_NOTE_NODE)
|
||||
handleNodeSelect(candidateNode.id)
|
||||
|
||||
if (candidateNode.data.type === BlockEnum.TriggerWebhook) {
|
||||
handleSyncWorkflowDraft(true, true, {
|
||||
onSuccess: () => autoGenerateWebhookUrl(candidateNode.id),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
useEventListener('contextmenu', (e) => {
|
||||
const { candidateNode } = workflowStore.getState()
|
||||
if (candidateNode) {
|
||||
e.preventDefault()
|
||||
workflowStore.setState({ candidateNode: undefined })
|
||||
}
|
||||
})
|
||||
|
||||
if (!candidateNode)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute z-10'
|
||||
style={{
|
||||
left: mousePosition.elementX,
|
||||
top: mousePosition.elementY,
|
||||
transform: `scale(${zoom})`,
|
||||
transformOrigin: '0 0',
|
||||
}}
|
||||
>
|
||||
{
|
||||
candidateNode.type === CUSTOM_NODE && (
|
||||
<CustomNode {...candidateNode as any} />
|
||||
)
|
||||
}
|
||||
{
|
||||
candidateNode.type === CUSTOM_NOTE_NODE && (
|
||||
<CustomNoteNode {...candidateNode as any} />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CandidateNode)
|
||||
257
dify/web/app/components/workflow/constants.ts
Normal file
257
dify/web/app/components/workflow/constants.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type { Var } from './types'
|
||||
import { BlockEnum, VarType } from './types'
|
||||
export const MAX_ITERATION_PARALLEL_NUM = 10
|
||||
export const MIN_ITERATION_PARALLEL_NUM = 1
|
||||
export const DEFAULT_ITER_TIMES = 1
|
||||
export const DEFAULT_LOOP_TIMES = 1
|
||||
export const NODE_WIDTH = 240
|
||||
export const X_OFFSET = 60
|
||||
export const NODE_WIDTH_X_OFFSET = NODE_WIDTH + X_OFFSET
|
||||
export const Y_OFFSET = 39
|
||||
export const START_INITIAL_POSITION = { x: 80, y: 282 }
|
||||
export const AUTO_LAYOUT_OFFSET = {
|
||||
x: -42,
|
||||
y: 243,
|
||||
}
|
||||
export const ITERATION_NODE_Z_INDEX = 1
|
||||
export const ITERATION_CHILDREN_Z_INDEX = 1002
|
||||
export const ITERATION_PADDING = {
|
||||
top: 65,
|
||||
right: 16,
|
||||
bottom: 20,
|
||||
left: 16,
|
||||
}
|
||||
|
||||
export const LOOP_NODE_Z_INDEX = 1
|
||||
export const LOOP_CHILDREN_Z_INDEX = 1002
|
||||
export const LOOP_PADDING = {
|
||||
top: 65,
|
||||
right: 16,
|
||||
bottom: 20,
|
||||
left: 16,
|
||||
}
|
||||
|
||||
export const NODE_LAYOUT_HORIZONTAL_PADDING = 60
|
||||
export const NODE_LAYOUT_VERTICAL_PADDING = 60
|
||||
export const NODE_LAYOUT_MIN_DISTANCE = 100
|
||||
|
||||
export const isInWorkflowPage = () => {
|
||||
const pathname = globalThis.location.pathname
|
||||
return /^\/app\/[^/]+\/workflow$/.test(pathname) || /^\/workflow\/[^/]+$/.test(pathname)
|
||||
}
|
||||
export const getGlobalVars = (isChatMode: boolean): Var[] => {
|
||||
const isInWorkflow = isInWorkflowPage()
|
||||
const vars: Var[] = [
|
||||
...(isChatMode ? [
|
||||
{
|
||||
variable: 'sys.dialogue_count',
|
||||
type: VarType.number,
|
||||
},
|
||||
{
|
||||
variable: 'sys.conversation_id',
|
||||
type: VarType.string,
|
||||
},
|
||||
] : []),
|
||||
{
|
||||
variable: 'sys.user_id',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'sys.app_id',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'sys.workflow_id',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'sys.workflow_run_id',
|
||||
type: VarType.string,
|
||||
},
|
||||
...((isInWorkflow && !isChatMode) ? [
|
||||
{
|
||||
variable: 'sys.timestamp',
|
||||
type: VarType.number,
|
||||
},
|
||||
] : []),
|
||||
]
|
||||
return vars
|
||||
}
|
||||
|
||||
export const VAR_SHOW_NAME_MAP: Record<string, string> = {
|
||||
'sys.query': 'query',
|
||||
'sys.files': 'files',
|
||||
}
|
||||
|
||||
export const RETRIEVAL_OUTPUT_STRUCT = `{
|
||||
"content": "",
|
||||
"title": "",
|
||||
"url": "",
|
||||
"icon": "",
|
||||
"metadata": {
|
||||
"dataset_id": "",
|
||||
"dataset_name": "",
|
||||
"document_id": [],
|
||||
"document_name": "",
|
||||
"document_data_source_type": "",
|
||||
"segment_id": "",
|
||||
"segment_position": "",
|
||||
"segment_word_count": "",
|
||||
"segment_hit_count": "",
|
||||
"segment_index_node_hash": "",
|
||||
"score": ""
|
||||
}
|
||||
}`
|
||||
|
||||
export const SUPPORT_OUTPUT_VARS_NODE = [
|
||||
BlockEnum.Start, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin, BlockEnum.LLM, BlockEnum.KnowledgeRetrieval, BlockEnum.Code, BlockEnum.TemplateTransform,
|
||||
BlockEnum.HttpRequest, BlockEnum.Tool, BlockEnum.VariableAssigner, BlockEnum.VariableAggregator, BlockEnum.QuestionClassifier,
|
||||
BlockEnum.ParameterExtractor, BlockEnum.Iteration, BlockEnum.Loop,
|
||||
BlockEnum.DocExtractor, BlockEnum.ListFilter,
|
||||
BlockEnum.Agent, BlockEnum.DataSource,
|
||||
]
|
||||
|
||||
export const AGENT_OUTPUT_STRUCT: Var[] = [
|
||||
{
|
||||
variable: 'usage',
|
||||
type: VarType.object,
|
||||
},
|
||||
]
|
||||
|
||||
export const LLM_OUTPUT_STRUCT: Var[] = [
|
||||
{
|
||||
variable: 'text',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'reasoning_content',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'usage',
|
||||
type: VarType.object,
|
||||
},
|
||||
]
|
||||
|
||||
export const KNOWLEDGE_RETRIEVAL_OUTPUT_STRUCT: Var[] = [
|
||||
{
|
||||
variable: 'result',
|
||||
type: VarType.arrayObject,
|
||||
},
|
||||
]
|
||||
|
||||
export const TEMPLATE_TRANSFORM_OUTPUT_STRUCT: Var[] = [
|
||||
{
|
||||
variable: 'output',
|
||||
type: VarType.string,
|
||||
},
|
||||
]
|
||||
|
||||
export const QUESTION_CLASSIFIER_OUTPUT_STRUCT = [
|
||||
{
|
||||
variable: 'class_name',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'usage',
|
||||
type: VarType.object,
|
||||
},
|
||||
]
|
||||
|
||||
export const HTTP_REQUEST_OUTPUT_STRUCT: Var[] = [
|
||||
{
|
||||
variable: 'body',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'status_code',
|
||||
type: VarType.number,
|
||||
},
|
||||
{
|
||||
variable: 'headers',
|
||||
type: VarType.object,
|
||||
},
|
||||
{
|
||||
variable: 'files',
|
||||
type: VarType.arrayFile,
|
||||
},
|
||||
]
|
||||
|
||||
export const TOOL_OUTPUT_STRUCT: Var[] = [
|
||||
{
|
||||
variable: 'text',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'files',
|
||||
type: VarType.arrayFile,
|
||||
},
|
||||
{
|
||||
variable: 'json',
|
||||
type: VarType.arrayObject,
|
||||
},
|
||||
]
|
||||
|
||||
export const PARAMETER_EXTRACTOR_COMMON_STRUCT: Var[] = [
|
||||
{
|
||||
variable: '__is_success',
|
||||
type: VarType.number,
|
||||
},
|
||||
{
|
||||
variable: '__reason',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: '__usage',
|
||||
type: VarType.object,
|
||||
},
|
||||
]
|
||||
|
||||
export const FILE_STRUCT: Var[] = [
|
||||
{
|
||||
variable: 'name',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'size',
|
||||
type: VarType.number,
|
||||
},
|
||||
{
|
||||
variable: 'type',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'extension',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'mime_type',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'transfer_method',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'url',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: 'related_id',
|
||||
type: VarType.string,
|
||||
},
|
||||
]
|
||||
|
||||
export const DEFAULT_FILE_UPLOAD_SETTING = {
|
||||
allowed_file_upload_methods: ['local_file', 'remote_url'],
|
||||
max_length: 5,
|
||||
allowed_file_types: ['image'],
|
||||
allowed_file_extensions: [],
|
||||
}
|
||||
|
||||
export const WORKFLOW_DATA_UPDATE = 'WORKFLOW_DATA_UPDATE'
|
||||
export const CUSTOM_NODE = 'custom'
|
||||
export const CUSTOM_EDGE = 'custom'
|
||||
export const DSL_EXPORT_CHECK = 'DSL_EXPORT_CHECK'
|
||||
export const DEFAULT_RETRY_MAX = 3
|
||||
export const DEFAULT_RETRY_INTERVAL = 100
|
||||
44
dify/web/app/components/workflow/constants/node.ts
Normal file
44
dify/web/app/components/workflow/constants/node.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import llmDefault from '@/app/components/workflow/nodes/llm/default'
|
||||
import knowledgeRetrievalDefault from '@/app/components/workflow/nodes/knowledge-retrieval/default'
|
||||
import agentDefault from '@/app/components/workflow/nodes/agent/default'
|
||||
|
||||
import questionClassifierDefault from '@/app/components/workflow/nodes/question-classifier/default'
|
||||
|
||||
import ifElseDefault from '@/app/components/workflow/nodes/if-else/default'
|
||||
import iterationDefault from '@/app/components/workflow/nodes/iteration/default'
|
||||
import iterationStartDefault from '@/app/components/workflow/nodes/iteration-start/default'
|
||||
import loopDefault from '@/app/components/workflow/nodes/loop/default'
|
||||
import loopStartDefault from '@/app/components/workflow/nodes/loop-start/default'
|
||||
import loopEndDefault from '@/app/components/workflow/nodes/loop-end/default'
|
||||
|
||||
import codeDefault from '@/app/components/workflow/nodes/code/default'
|
||||
import templateTransformDefault from '@/app/components/workflow/nodes/template-transform/default'
|
||||
import variableAggregatorDefault from '@/app/components/workflow/nodes/variable-assigner/default'
|
||||
import documentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default'
|
||||
import assignerDefault from '@/app/components/workflow/nodes/assigner/default'
|
||||
import httpRequestDefault from '@/app/components/workflow/nodes/http/default'
|
||||
import parameterExtractorDefault from '@/app/components/workflow/nodes/parameter-extractor/default'
|
||||
import listOperatorDefault from '@/app/components/workflow/nodes/list-operator/default'
|
||||
import toolDefault from '@/app/components/workflow/nodes/tool/default'
|
||||
|
||||
export const WORKFLOW_COMMON_NODES = [
|
||||
llmDefault,
|
||||
knowledgeRetrievalDefault,
|
||||
agentDefault,
|
||||
questionClassifierDefault,
|
||||
ifElseDefault,
|
||||
iterationDefault,
|
||||
iterationStartDefault,
|
||||
loopDefault,
|
||||
loopStartDefault,
|
||||
loopEndDefault,
|
||||
codeDefault,
|
||||
templateTransformDefault,
|
||||
variableAggregatorDefault,
|
||||
documentExtractorDefault,
|
||||
assignerDefault,
|
||||
parameterExtractorDefault,
|
||||
httpRequestDefault,
|
||||
listOperatorDefault,
|
||||
toolDefault,
|
||||
]
|
||||
29
dify/web/app/components/workflow/context.tsx
Normal file
29
dify/web/app/components/workflow/context.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
createContext,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import type { SliceFromInjection } from './store'
|
||||
import {
|
||||
createWorkflowStore,
|
||||
} from './store'
|
||||
import type { StateCreator } from 'zustand'
|
||||
|
||||
type WorkflowStore = ReturnType<typeof createWorkflowStore>
|
||||
export const WorkflowContext = createContext<WorkflowStore | null>(null)
|
||||
|
||||
export type WorkflowProviderProps = {
|
||||
children: React.ReactNode
|
||||
injectWorkflowStoreSliceFn?: StateCreator<SliceFromInjection>
|
||||
}
|
||||
export const WorkflowContextProvider = ({ children, injectWorkflowStoreSliceFn }: WorkflowProviderProps) => {
|
||||
const storeRef = useRef<WorkflowStore | undefined>(undefined)
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createWorkflowStore({ injectWorkflowStoreSliceFn })
|
||||
|
||||
return (
|
||||
<WorkflowContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</WorkflowContext.Provider>
|
||||
)
|
||||
}
|
||||
40
dify/web/app/components/workflow/custom-connection-line.tsx
Normal file
40
dify/web/app/components/workflow/custom-connection-line.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { memo } from 'react'
|
||||
import type { ConnectionLineComponentProps } from 'reactflow'
|
||||
import {
|
||||
Position,
|
||||
getBezierPath,
|
||||
} from 'reactflow'
|
||||
|
||||
const CustomConnectionLine = ({ fromX, fromY, toX, toY }: ConnectionLineComponentProps) => {
|
||||
const [
|
||||
edgePath,
|
||||
] = getBezierPath({
|
||||
sourceX: fromX,
|
||||
sourceY: fromY,
|
||||
sourcePosition: Position.Right,
|
||||
targetX: toX,
|
||||
targetY: toY,
|
||||
targetPosition: Position.Left,
|
||||
curvature: 0.16,
|
||||
})
|
||||
|
||||
return (
|
||||
<g>
|
||||
<path
|
||||
fill="none"
|
||||
stroke='#D0D5DD'
|
||||
strokeWidth={2}
|
||||
d={edgePath}
|
||||
/>
|
||||
<rect
|
||||
x={toX}
|
||||
y={toY - 4}
|
||||
width={2}
|
||||
height={8}
|
||||
fill='#2970FF'
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomConnectionLine)
|
||||
@@ -0,0 +1,53 @@
|
||||
type CustomEdgeLinearGradientRenderProps = {
|
||||
id: string
|
||||
startColor: string
|
||||
stopColor: string
|
||||
position: {
|
||||
x1: number
|
||||
x2: number
|
||||
y1: number
|
||||
y2: number
|
||||
}
|
||||
}
|
||||
const CustomEdgeLinearGradientRender = ({
|
||||
id,
|
||||
startColor,
|
||||
stopColor,
|
||||
position,
|
||||
}: CustomEdgeLinearGradientRenderProps) => {
|
||||
const {
|
||||
x1,
|
||||
x2,
|
||||
y1,
|
||||
y2,
|
||||
} = position
|
||||
return (
|
||||
<defs>
|
||||
<linearGradient
|
||||
id={id}
|
||||
gradientUnits='userSpaceOnUse'
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
>
|
||||
<stop
|
||||
offset='0%'
|
||||
style={{
|
||||
stopColor: startColor,
|
||||
stopOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
<stop
|
||||
offset='100%'
|
||||
style={{
|
||||
stopColor,
|
||||
stopOpacity: 1,
|
||||
}}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomEdgeLinearGradientRender
|
||||
171
dify/web/app/components/workflow/custom-edge.tsx
Normal file
171
dify/web/app/components/workflow/custom-edge.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { intersection } from 'lodash-es'
|
||||
import type { EdgeProps } from 'reactflow'
|
||||
import {
|
||||
BaseEdge,
|
||||
EdgeLabelRenderer,
|
||||
Position,
|
||||
getBezierPath,
|
||||
} from 'reactflow'
|
||||
import {
|
||||
useAvailableBlocks,
|
||||
useNodesInteractions,
|
||||
} from './hooks'
|
||||
import BlockSelector from './block-selector'
|
||||
import type {
|
||||
Edge,
|
||||
OnSelectBlock,
|
||||
} from './types'
|
||||
import { NodeRunningStatus } from './types'
|
||||
import { getEdgeColor } from './utils'
|
||||
import { ITERATION_CHILDREN_Z_INDEX, LOOP_CHILDREN_Z_INDEX } from './constants'
|
||||
import CustomEdgeLinearGradientRender from './custom-edge-linear-gradient-render'
|
||||
import cn from '@/utils/classnames'
|
||||
import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types'
|
||||
|
||||
const CustomEdge = ({
|
||||
id,
|
||||
data,
|
||||
source,
|
||||
sourceHandleId,
|
||||
target,
|
||||
targetHandleId,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
selected,
|
||||
}: EdgeProps) => {
|
||||
const [
|
||||
edgePath,
|
||||
labelX,
|
||||
labelY,
|
||||
] = getBezierPath({
|
||||
sourceX: sourceX - 8,
|
||||
sourceY,
|
||||
sourcePosition: Position.Right,
|
||||
targetX: targetX + 8,
|
||||
targetY,
|
||||
targetPosition: Position.Left,
|
||||
curvature: 0.16,
|
||||
})
|
||||
const [open, setOpen] = useState(false)
|
||||
const { handleNodeAdd } = useNodesInteractions()
|
||||
const { availablePrevBlocks } = useAvailableBlocks((data as Edge['data'])!.targetType, (data as Edge['data'])?.isInIteration || (data as Edge['data'])?.isInLoop)
|
||||
const { availableNextBlocks } = useAvailableBlocks((data as Edge['data'])!.sourceType, (data as Edge['data'])?.isInIteration || (data as Edge['data'])?.isInLoop)
|
||||
const {
|
||||
_sourceRunningStatus,
|
||||
_targetRunningStatus,
|
||||
} = data
|
||||
|
||||
const linearGradientId = useMemo(() => {
|
||||
if (
|
||||
(
|
||||
_sourceRunningStatus === NodeRunningStatus.Succeeded
|
||||
|| _sourceRunningStatus === NodeRunningStatus.Failed
|
||||
|| _sourceRunningStatus === NodeRunningStatus.Exception
|
||||
) && (
|
||||
_targetRunningStatus === NodeRunningStatus.Succeeded
|
||||
|| _targetRunningStatus === NodeRunningStatus.Failed
|
||||
|| _targetRunningStatus === NodeRunningStatus.Exception
|
||||
|| _targetRunningStatus === NodeRunningStatus.Running
|
||||
)
|
||||
)
|
||||
return id
|
||||
}, [_sourceRunningStatus, _targetRunningStatus, id])
|
||||
|
||||
const handleOpenChange = useCallback((v: boolean) => {
|
||||
setOpen(v)
|
||||
}, [])
|
||||
|
||||
const handleInsert = useCallback<OnSelectBlock>((nodeType, pluginDefaultValue) => {
|
||||
handleNodeAdd(
|
||||
{
|
||||
nodeType,
|
||||
pluginDefaultValue,
|
||||
},
|
||||
{
|
||||
prevNodeId: source,
|
||||
prevNodeSourceHandle: sourceHandleId || 'source',
|
||||
nextNodeId: target,
|
||||
nextNodeTargetHandle: targetHandleId || 'target',
|
||||
},
|
||||
)
|
||||
}, [handleNodeAdd, source, sourceHandleId, target, targetHandleId])
|
||||
|
||||
const stroke = useMemo(() => {
|
||||
if (selected)
|
||||
return getEdgeColor(NodeRunningStatus.Running)
|
||||
|
||||
if (linearGradientId)
|
||||
return `url(#${linearGradientId})`
|
||||
|
||||
if (data?._connectedNodeIsHovering)
|
||||
return getEdgeColor(NodeRunningStatus.Running, sourceHandleId === ErrorHandleTypeEnum.failBranch)
|
||||
|
||||
return getEdgeColor()
|
||||
}, [data._connectedNodeIsHovering, linearGradientId, selected, sourceHandleId])
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
linearGradientId && (
|
||||
<CustomEdgeLinearGradientRender
|
||||
id={linearGradientId}
|
||||
startColor={getEdgeColor(_sourceRunningStatus)}
|
||||
stopColor={getEdgeColor(_targetRunningStatus)}
|
||||
position={{
|
||||
x1: sourceX,
|
||||
y1: sourceY,
|
||||
x2: targetX,
|
||||
y2: targetY,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
stroke,
|
||||
strokeWidth: 2,
|
||||
opacity: data._dimmed ? 0.3 : (data._waitingRun ? 0.7 : 1),
|
||||
strokeDasharray: data._isTemp ? '8 8' : undefined,
|
||||
}}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
className={cn(
|
||||
'nopan nodrag hover:scale-125',
|
||||
data?._hovering ? 'block' : 'hidden',
|
||||
open && '!block',
|
||||
data.isInIteration && `z-[${ITERATION_CHILDREN_Z_INDEX}]`,
|
||||
data.isInLoop && `z-[${LOOP_CHILDREN_Z_INDEX}]`,
|
||||
)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
transform: `translate(-50%, -50%) translate(${labelX}px, ${labelY}px)`,
|
||||
pointerEvents: 'all',
|
||||
opacity: data._waitingRun ? 0.7 : 1,
|
||||
}}
|
||||
>
|
||||
<BlockSelector
|
||||
open={open}
|
||||
onOpenChange={handleOpenChange}
|
||||
asChild
|
||||
onSelect={handleInsert}
|
||||
availableBlocksTypes={intersection(availablePrevBlocks, availableNextBlocks)}
|
||||
triggerClassName={() => 'hover:scale-150 transition-all'}
|
||||
/>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(CustomEdge)
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { FC } from 'react'
|
||||
import { createContext, useCallback, useEffect, useRef } from 'react'
|
||||
import { createDatasetsDetailStore } from './store'
|
||||
import type { CommonNodeType, Node } from '../types'
|
||||
import { BlockEnum } from '../types'
|
||||
import type { KnowledgeRetrievalNodeType } from '../nodes/knowledge-retrieval/types'
|
||||
import { fetchDatasets } from '@/service/datasets'
|
||||
|
||||
type DatasetsDetailStoreApi = ReturnType<typeof createDatasetsDetailStore>
|
||||
|
||||
type DatasetsDetailContextType = DatasetsDetailStoreApi | undefined
|
||||
|
||||
export const DatasetsDetailContext = createContext<DatasetsDetailContextType>(undefined)
|
||||
|
||||
type DatasetsDetailProviderProps = {
|
||||
nodes: Node[]
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
const DatasetsDetailProvider: FC<DatasetsDetailProviderProps> = ({
|
||||
nodes,
|
||||
children,
|
||||
}) => {
|
||||
const storeRef = useRef<DatasetsDetailStoreApi>(undefined)
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createDatasetsDetailStore()
|
||||
|
||||
const updateDatasetsDetail = useCallback(async (datasetIds: string[]) => {
|
||||
const { data: datasetsDetail } = await fetchDatasets({ url: '/datasets', params: { page: 1, ids: datasetIds } })
|
||||
if (datasetsDetail && datasetsDetail.length > 0)
|
||||
storeRef.current!.getState().updateDatasetsDetail(datasetsDetail)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!storeRef.current) return
|
||||
const knowledgeRetrievalNodes = nodes.filter(node => node.data.type === BlockEnum.KnowledgeRetrieval)
|
||||
const allDatasetIds = knowledgeRetrievalNodes.reduce<string[]>((acc, node) => {
|
||||
return Array.from(new Set([...acc, ...(node.data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids]))
|
||||
}, [])
|
||||
if (allDatasetIds.length === 0) return
|
||||
updateDatasetsDetail(allDatasetIds)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<DatasetsDetailContext.Provider value={storeRef.current!}>
|
||||
{children}
|
||||
</DatasetsDetailContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatasetsDetailProvider
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useContext } from 'react'
|
||||
import { createStore, useStore } from 'zustand'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { DatasetsDetailContext } from './provider'
|
||||
import { produce } from 'immer'
|
||||
|
||||
type DatasetsDetailStore = {
|
||||
datasetsDetail: Record<string, DataSet>
|
||||
updateDatasetsDetail: (datasetsDetail: DataSet[]) => void
|
||||
}
|
||||
|
||||
export const createDatasetsDetailStore = () => {
|
||||
return createStore<DatasetsDetailStore>((set, get) => ({
|
||||
datasetsDetail: {},
|
||||
updateDatasetsDetail: (datasets: DataSet[]) => {
|
||||
const oldDatasetsDetail = get().datasetsDetail
|
||||
const datasetsDetail = datasets.reduce<Record<string, DataSet>>((acc, dataset) => {
|
||||
acc[dataset.id] = dataset
|
||||
return acc
|
||||
}, {})
|
||||
// Merge new datasets detail into old one
|
||||
const newDatasetsDetail = produce(oldDatasetsDetail, (draft) => {
|
||||
Object.entries(datasetsDetail).forEach(([key, value]) => {
|
||||
draft[key] = value
|
||||
})
|
||||
})
|
||||
set({ datasetsDetail: newDatasetsDetail })
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export const useDatasetsDetailStore = <T>(selector: (state: DatasetsDetailStore) => T): T => {
|
||||
const store = useContext(DatasetsDetailContext)
|
||||
if (!store)
|
||||
throw new Error('Missing DatasetsDetailContext.Provider in the tree')
|
||||
|
||||
return useStore(store, selector)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine, RiLock2Line } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { Env } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type DSLExportConfirmModalProps = {
|
||||
envList: EnvironmentVariable[]
|
||||
onConfirm: (state: boolean) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const DSLExportConfirmModal = ({
|
||||
envList = [],
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: DSLExportConfirmModalProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [exportSecrets, setExportSecrets] = useState<boolean>(false)
|
||||
|
||||
const submit = () => {
|
||||
onConfirm(exportSecrets)
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={true}
|
||||
onClose={noop}
|
||||
className={cn('w-[480px] max-w-[480px]')}
|
||||
>
|
||||
<div className='title-2xl-semi-bold relative pb-6 text-text-primary'>{t('workflow.env.export.title')}</div>
|
||||
<div className='absolute right-4 top-4 cursor-pointer p-2' onClick={onClose}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
<div className='relative'>
|
||||
<table className='radius-md w-full border-separate border-spacing-0 border border-divider-regular shadow-xs'>
|
||||
<thead className='system-xs-medium-uppercase text-text-tertiary'>
|
||||
<tr>
|
||||
<td width={220} className='h-7 border-b border-r border-divider-regular pl-3'>NAME</td>
|
||||
<td className='h-7 border-b border-divider-regular pl-3'>VALUE</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{envList.map((env, index) => (
|
||||
<tr key={env.name}>
|
||||
<td className={cn('system-xs-medium h-7 border-r pl-3', index + 1 !== envList.length && 'border-b')}>
|
||||
<div className='flex w-[200px] items-center gap-1'>
|
||||
<Env className='h-4 w-4 shrink-0 text-util-colors-violet-violet-600' />
|
||||
<div className='truncate text-text-primary'>{env.name}</div>
|
||||
<div className='shrink-0 text-text-tertiary'>Secret</div>
|
||||
<RiLock2Line className='h-3 w-3 shrink-0 text-text-tertiary' />
|
||||
</div>
|
||||
</td>
|
||||
<td className={cn('h-7 pl-3', index + 1 !== envList.length && 'border-b')}>
|
||||
<div className='system-xs-regular truncate text-text-secondary'>{env.value}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className='mt-4 flex gap-2'>
|
||||
<Checkbox
|
||||
className='shrink-0'
|
||||
checked={exportSecrets}
|
||||
onCheck={() => setExportSecrets(!exportSecrets)}
|
||||
/>
|
||||
<div className='system-sm-medium cursor-pointer text-text-primary' onClick={() => setExportSecrets(!exportSecrets)}>{t('workflow.env.export.checkbox')}</div>
|
||||
</div>
|
||||
<div className='flex flex-row-reverse pt-6'>
|
||||
<Button className='ml-2' variant='primary' onClick={submit}>{exportSecrets ? t('workflow.env.export.export') : t('workflow.env.export.ignore')}</Button>
|
||||
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default DSLExportConfirmModal
|
||||
60
dify/web/app/components/workflow/features.tsx
Normal file
60
dify/web/app/components/workflow/features.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { useStore } from './store'
|
||||
import {
|
||||
useIsChatMode,
|
||||
useNodesReadOnly,
|
||||
useNodesSyncDraft,
|
||||
} from './hooks'
|
||||
import { type CommonNodeType, type InputVar, InputVarType, type Node } from './types'
|
||||
import useConfig from './nodes/start/use-config'
|
||||
import type { StartNodeType } from './nodes/start/types'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import NewFeaturePanel from '@/app/components/base/features/new-feature-panel'
|
||||
|
||||
const Features = () => {
|
||||
const setShowFeaturesPanel = useStore(s => s.setShowFeaturesPanel)
|
||||
const isChatMode = useIsChatMode()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const nodes = useNodes<CommonNodeType>()
|
||||
|
||||
const startNode = nodes.find(node => node.data.type === 'start')
|
||||
const { id, data } = startNode as Node<StartNodeType>
|
||||
const { handleAddVariable } = useConfig(id, data)
|
||||
|
||||
const handleAddOpeningStatementVariable = (variables: PromptVariable[]) => {
|
||||
const newVariable = variables[0]
|
||||
const startNodeVariable: InputVar = {
|
||||
variable: newVariable.key,
|
||||
label: newVariable.name,
|
||||
type: InputVarType.textInput,
|
||||
max_length: newVariable.max_length,
|
||||
required: newVariable.required || false,
|
||||
options: [],
|
||||
}
|
||||
handleAddVariable(startNodeVariable)
|
||||
}
|
||||
|
||||
const handleFeaturesChange = useCallback(() => {
|
||||
handleSyncWorkflowDraft()
|
||||
setShowFeaturesPanel(true)
|
||||
}, [handleSyncWorkflowDraft, setShowFeaturesPanel])
|
||||
|
||||
return (
|
||||
<NewFeaturePanel
|
||||
show
|
||||
isChatMode={isChatMode}
|
||||
disabled={nodesReadOnly}
|
||||
onChange={handleFeaturesChange}
|
||||
onClose={() => setShowFeaturesPanel(false)}
|
||||
onAutoAddPromptVariable={handleAddOpeningStatementVariable}
|
||||
workflowVariables={data.variables}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Features)
|
||||
@@ -0,0 +1,38 @@
|
||||
import { memo } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { BubbleX } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const ChatVariableButton = ({ disabled }: { disabled: boolean }) => {
|
||||
const { theme } = useTheme()
|
||||
const showChatVariablePanel = useStore(s => s.showChatVariablePanel)
|
||||
const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel)
|
||||
const setShowEnvPanel = useStore(s => s.setShowEnvPanel)
|
||||
const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel)
|
||||
const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel)
|
||||
|
||||
const handleClick = () => {
|
||||
setShowChatVariablePanel(true)
|
||||
setShowEnvPanel(false)
|
||||
setShowGlobalVariablePanel(false)
|
||||
setShowDebugAndPreviewPanel(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'p-2',
|
||||
theme === 'dark' && showChatVariablePanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
variant='ghost'
|
||||
>
|
||||
<BubbleX className='h-4 w-4 text-components-button-secondary-text' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ChatVariableButton)
|
||||
192
dify/web/app/components/workflow/header/checklist.tsx
Normal file
192
dify/web/app/components/workflow/header/checklist.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useEdges,
|
||||
} from 'reactflow'
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiListCheck3,
|
||||
} from '@remixicon/react'
|
||||
import BlockIcon from '../block-icon'
|
||||
import {
|
||||
useChecklist,
|
||||
useNodesInteractions,
|
||||
} from '../hooks'
|
||||
import type { ChecklistItem } from '../hooks/use-checklist'
|
||||
import type {
|
||||
CommonEdgeType,
|
||||
} from '../types'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import {
|
||||
ChecklistSquare,
|
||||
} from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { Warning } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
|
||||
import { IconR } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import type {
|
||||
BlockEnum,
|
||||
} from '../types'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
|
||||
type WorkflowChecklistProps = {
|
||||
disabled: boolean
|
||||
}
|
||||
const WorkflowChecklist = ({
|
||||
disabled,
|
||||
}: WorkflowChecklistProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const edges = useEdges<CommonEdgeType>()
|
||||
const nodes = useNodes()
|
||||
const needWarningNodes = useChecklist(nodes, edges)
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
|
||||
const handleChecklistItemClick = (item: ChecklistItem) => {
|
||||
if (!item.canNavigate)
|
||||
return
|
||||
handleNodeSelect(item.id)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 12,
|
||||
crossAxis: 4,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => !disabled && setOpen(v => !v)}>
|
||||
<div
|
||||
className={cn(
|
||||
'relative ml-0.5 flex h-7 w-7 items-center justify-center rounded-md',
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn('group flex h-full w-full cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
|
||||
>
|
||||
<RiListCheck3
|
||||
className={cn('h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')}
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
!!needWarningNodes.length && (
|
||||
<div className='absolute -right-1.5 -top-1.5 flex h-[18px] min-w-[18px] items-center justify-center rounded-full border border-gray-100 bg-[#F79009] text-[11px] font-semibold text-white'>
|
||||
{needWarningNodes.length}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[12]'>
|
||||
<div
|
||||
className='w-[420px] overflow-y-auto rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg'
|
||||
style={{
|
||||
maxHeight: 'calc(2 / 3 * 100vh)',
|
||||
}}
|
||||
>
|
||||
<div className='text-md sticky top-0 z-[1] flex h-[44px] items-center bg-components-panel-bg pl-4 pr-3 pt-3 font-semibold text-text-primary'>
|
||||
<div className='grow'>{t('workflow.panel.checklist')}{needWarningNodes.length ? `(${needWarningNodes.length})` : ''}</div>
|
||||
<div
|
||||
className='flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center'
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='pb-2'>
|
||||
{
|
||||
!!needWarningNodes.length && (
|
||||
<>
|
||||
<div className='px-4 pt-1 text-xs text-text-tertiary'>{t('workflow.panel.checklistTip')}</div>
|
||||
<div className='px-4 py-2'>
|
||||
{
|
||||
needWarningNodes.map(node => (
|
||||
<div
|
||||
key={node.id}
|
||||
className={cn(
|
||||
'group mb-2 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xs last-of-type:mb-0',
|
||||
node.canNavigate ? 'cursor-pointer' : 'cursor-default opacity-80',
|
||||
)}
|
||||
onClick={() => handleChecklistItemClick(node)}
|
||||
>
|
||||
<div className='flex h-9 items-center p-2 text-xs font-medium text-text-secondary'>
|
||||
<BlockIcon
|
||||
type={node.type as BlockEnum}
|
||||
className='mr-1.5'
|
||||
toolIcon={node.toolIcon}
|
||||
/>
|
||||
<span className='grow truncate'>
|
||||
{node.title}
|
||||
</span>
|
||||
{
|
||||
node.canNavigate && (
|
||||
<div className='flex h-4 w-[60px] shrink-0 items-center justify-center gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100'>
|
||||
<span className='whitespace-nowrap text-xs font-medium leading-4 text-primary-600'>
|
||||
{t('workflow.panel.goTo')}
|
||||
</span>
|
||||
<IconR className='h-3.5 w-3.5 text-primary-600' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-b-lg border-t-[0.5px] border-divider-regular',
|
||||
(node.unConnected || node.errorMessage) && 'bg-gradient-to-r from-components-badge-bg-orange-soft to-transparent',
|
||||
)}
|
||||
>
|
||||
{
|
||||
node.unConnected && (
|
||||
<div className='px-3 py-1 first:pt-1.5 last:pb-1.5'>
|
||||
<div className='flex text-xs leading-4 text-text-tertiary'>
|
||||
<Warning className='mr-2 mt-[2px] h-3 w-3 text-[#F79009]' />
|
||||
{t('workflow.common.needConnectTip')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
node.errorMessage && (
|
||||
<div className='px-3 py-1 first:pt-1.5 last:pb-1.5'>
|
||||
<div className='flex text-xs leading-4 text-text-tertiary'>
|
||||
<Warning className='mr-2 mt-[2px] h-3 w-3 text-[#F79009]' />
|
||||
{node.errorMessage}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
!needWarningNodes.length && (
|
||||
<div className='mx-4 mb-3 rounded-lg bg-components-panel-bg py-4 text-center text-xs text-text-tertiary'>
|
||||
<ChecklistSquare className='mx-auto mb-[5px] h-8 w-8 text-text-quaternary' />
|
||||
{t('workflow.panel.checklistResolved')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WorkflowChecklist)
|
||||
43
dify/web/app/components/workflow/header/editing-title.tsx
Normal file
43
dify/web/app/components/workflow/header/editing-title.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
|
||||
const EditingTitle = () => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const draftUpdatedAt = useStore(state => state.draftUpdatedAt)
|
||||
const publishedAt = useStore(state => state.publishedAt)
|
||||
const isSyncingWorkflowDraft = useStore(s => s.isSyncingWorkflowDraft)
|
||||
const maximizeCanvas = useStore(s => s.maximizeCanvas)
|
||||
|
||||
return (
|
||||
<div className={`system-xs-regular flex h-[18px] min-w-[300px] items-center whitespace-nowrap text-text-tertiary ${maximizeCanvas ? 'ml-2' : ''}`}>
|
||||
{
|
||||
!!draftUpdatedAt && (
|
||||
<>
|
||||
{t('workflow.common.autoSaved')} {formatTime(draftUpdatedAt / 1000, 'HH:mm:ss')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
<span className='mx-1 flex items-center'>·</span>
|
||||
{
|
||||
publishedAt
|
||||
? `${t('workflow.common.published')} ${formatTimeFromNow(publishedAt)}`
|
||||
: t('workflow.common.unpublished')
|
||||
}
|
||||
{
|
||||
isSyncingWorkflowDraft && (
|
||||
<>
|
||||
<span className='mx-1 flex items-center'>·</span>
|
||||
{t('workflow.common.syncingData')}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(EditingTitle)
|
||||
41
dify/web/app/components/workflow/header/env-button.tsx
Normal file
41
dify/web/app/components/workflow/header/env-button.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { memo } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Env } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
|
||||
|
||||
const EnvButton = ({ disabled }: { disabled: boolean }) => {
|
||||
const { theme } = useTheme()
|
||||
const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel)
|
||||
const showEnvPanel = useStore(s => s.showEnvPanel)
|
||||
const setShowEnvPanel = useStore(s => s.setShowEnvPanel)
|
||||
const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel)
|
||||
const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel)
|
||||
const { closeAllInputFieldPanels } = useInputFieldPanel()
|
||||
|
||||
const handleClick = () => {
|
||||
setShowEnvPanel(true)
|
||||
setShowChatVariablePanel(false)
|
||||
setShowGlobalVariablePanel(false)
|
||||
setShowDebugAndPreviewPanel(false)
|
||||
closeAllInputFieldPanels()
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'p-2',
|
||||
theme === 'dark' && showEnvPanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
variant='ghost'
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Env className='h-4 w-4 text-components-button-secondary-text' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(EnvButton)
|
||||
@@ -0,0 +1,41 @@
|
||||
import { memo } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { GlobalVariable } from '@/app/components/base/icons/src/vender/line/others'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
|
||||
|
||||
const GlobalVariableButton = ({ disabled }: { disabled: boolean }) => {
|
||||
const { theme } = useTheme()
|
||||
const showGlobalVariablePanel = useStore(s => s.showGlobalVariablePanel)
|
||||
const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel)
|
||||
const setShowEnvPanel = useStore(s => s.setShowEnvPanel)
|
||||
const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel)
|
||||
const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel)
|
||||
const { closeAllInputFieldPanels } = useInputFieldPanel()
|
||||
|
||||
const handleClick = () => {
|
||||
setShowGlobalVariablePanel(true)
|
||||
setShowEnvPanel(false)
|
||||
setShowChatVariablePanel(false)
|
||||
setShowDebugAndPreviewPanel(false)
|
||||
closeAllInputFieldPanels()
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'p-2',
|
||||
theme === 'dark' && showGlobalVariablePanel && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={handleClick}
|
||||
variant='ghost'
|
||||
>
|
||||
<GlobalVariable className='h-4 w-4 text-components-button-secondary-text' />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(GlobalVariableButton)
|
||||
90
dify/web/app/components/workflow/header/header-in-normal.tsx
Normal file
90
dify/web/app/components/workflow/header/header-in-normal.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from '../store'
|
||||
import type { StartNodeType } from '../nodes/start/types'
|
||||
import {
|
||||
useNodesInteractions,
|
||||
useNodesReadOnly,
|
||||
useWorkflowRun,
|
||||
} from '../hooks'
|
||||
import Divider from '../../base/divider'
|
||||
import type { RunAndHistoryProps } from './run-and-history'
|
||||
import RunAndHistory from './run-and-history'
|
||||
import EditingTitle from './editing-title'
|
||||
import EnvButton from './env-button'
|
||||
import VersionHistoryButton from './version-history-button'
|
||||
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
|
||||
import ScrollToSelectedNodeButton from './scroll-to-selected-node-button'
|
||||
import GlobalVariableButton from './global-variable-button'
|
||||
|
||||
export type HeaderInNormalProps = {
|
||||
components?: {
|
||||
left?: React.ReactNode
|
||||
middle?: React.ReactNode
|
||||
chatVariableTrigger?: React.ReactNode
|
||||
}
|
||||
runAndHistoryProps?: RunAndHistoryProps
|
||||
}
|
||||
const HeaderInNormal = ({
|
||||
components,
|
||||
runAndHistoryProps,
|
||||
}: HeaderInNormalProps) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
|
||||
const setShowEnvPanel = useStore(s => s.setShowEnvPanel)
|
||||
const setShowDebugAndPreviewPanel = useStore(s => s.setShowDebugAndPreviewPanel)
|
||||
const setShowVariableInspectPanel = useStore(s => s.setShowVariableInspectPanel)
|
||||
const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel)
|
||||
const setShowGlobalVariablePanel = useStore(s => s.setShowGlobalVariablePanel)
|
||||
const nodes = useNodes<StartNodeType>()
|
||||
const selectedNode = nodes.find(node => node.data.selected)
|
||||
const { handleBackupDraft } = useWorkflowRun()
|
||||
const { closeAllInputFieldPanels } = useInputFieldPanel()
|
||||
|
||||
const onStartRestoring = useCallback(() => {
|
||||
workflowStore.setState({ isRestoring: true })
|
||||
handleBackupDraft()
|
||||
// clear right panel
|
||||
if (selectedNode)
|
||||
handleNodeSelect(selectedNode.id, true)
|
||||
setShowWorkflowVersionHistoryPanel(true)
|
||||
setShowEnvPanel(false)
|
||||
setShowDebugAndPreviewPanel(false)
|
||||
setShowVariableInspectPanel(false)
|
||||
setShowChatVariablePanel(false)
|
||||
setShowGlobalVariablePanel(false)
|
||||
closeAllInputFieldPanels()
|
||||
}, [workflowStore, handleBackupDraft, selectedNode, handleNodeSelect, setShowWorkflowVersionHistoryPanel, setShowEnvPanel, setShowDebugAndPreviewPanel, setShowVariableInspectPanel, setShowChatVariablePanel, setShowGlobalVariablePanel])
|
||||
|
||||
return (
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<div>
|
||||
<EditingTitle />
|
||||
</div>
|
||||
<div>
|
||||
<ScrollToSelectedNodeButton />
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{components?.left}
|
||||
<Divider type='vertical' className='mx-auto h-3.5' />
|
||||
<RunAndHistory {...runAndHistoryProps} />
|
||||
<div className='shrink-0 cursor-pointer rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg shadow-xs backdrop-blur-[10px]'>
|
||||
{components?.chatVariableTrigger}
|
||||
<EnvButton disabled={nodesReadOnly} />
|
||||
<GlobalVariableButton disabled={nodesReadOnly} />
|
||||
</div>
|
||||
{components?.middle}
|
||||
<VersionHistoryButton onClick={onStartRestoring} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderInNormal
|
||||
111
dify/web/app/components/workflow/header/header-in-restoring.tsx
Normal file
111
dify/web/app/components/workflow/header/header-in-restoring.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { RiHistoryLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from '../store'
|
||||
import {
|
||||
WorkflowVersion,
|
||||
} from '../types'
|
||||
import {
|
||||
useNodesSyncDraft,
|
||||
useWorkflowRun,
|
||||
} from '../hooks'
|
||||
import Toast from '../../base/toast'
|
||||
import RestoringTitle from './restoring-title'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { useInvalidAllLastRun } from '@/service/use-workflow'
|
||||
import { useHooksStore } from '../hooks-store'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type HeaderInRestoringProps = {
|
||||
onRestoreSettled?: () => void
|
||||
}
|
||||
const HeaderInRestoring = ({
|
||||
onRestoreSettled,
|
||||
}: HeaderInRestoringProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { theme } = useTheme()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const configsMap = useHooksStore(s => s.configsMap)
|
||||
const invalidAllLastRun = useInvalidAllLastRun(configsMap?.flowType, configsMap?.flowId)
|
||||
const {
|
||||
deleteAllInspectVars,
|
||||
} = workflowStore.getState()
|
||||
const currentVersion = useStore(s => s.currentVersion)
|
||||
const setShowWorkflowVersionHistoryPanel = useStore(s => s.setShowWorkflowVersionHistoryPanel)
|
||||
|
||||
const {
|
||||
handleLoadBackupDraft,
|
||||
} = useWorkflowRun()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
|
||||
const handleCancelRestore = useCallback(() => {
|
||||
handleLoadBackupDraft()
|
||||
workflowStore.setState({ isRestoring: false })
|
||||
setShowWorkflowVersionHistoryPanel(false)
|
||||
}, [workflowStore, handleLoadBackupDraft, setShowWorkflowVersionHistoryPanel])
|
||||
|
||||
const handleRestore = useCallback(() => {
|
||||
setShowWorkflowVersionHistoryPanel(false)
|
||||
workflowStore.setState({ isRestoring: false })
|
||||
workflowStore.setState({ backupDraft: undefined })
|
||||
handleSyncWorkflowDraft(true, false, {
|
||||
onSuccess: () => {
|
||||
Toast.notify({
|
||||
type: 'success',
|
||||
message: t('workflow.versionHistory.action.restoreSuccess'),
|
||||
})
|
||||
},
|
||||
onError: () => {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('workflow.versionHistory.action.restoreFailure'),
|
||||
})
|
||||
},
|
||||
onSettled: () => {
|
||||
onRestoreSettled?.()
|
||||
},
|
||||
})
|
||||
deleteAllInspectVars()
|
||||
invalidAllLastRun()
|
||||
}, [setShowWorkflowVersionHistoryPanel, workflowStore, handleSyncWorkflowDraft, deleteAllInspectVars, invalidAllLastRun, t, onRestoreSettled])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<RestoringTitle />
|
||||
</div>
|
||||
<div className=' flex items-center justify-end gap-x-2'>
|
||||
<Button
|
||||
onClick={handleRestore}
|
||||
disabled={!currentVersion || currentVersion.version === WorkflowVersion.Draft}
|
||||
variant='primary'
|
||||
className={cn(
|
||||
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
>
|
||||
{t('workflow.common.restore')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancelRestore}
|
||||
className={cn(
|
||||
'text-components-button-secondary-accent-text',
|
||||
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
>
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
<RiHistoryLine className='h-4 w-4' />
|
||||
<span className='px-0.5'>{t('workflow.common.exitVersions')}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderInRestoring
|
||||
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
useWorkflowStore,
|
||||
} from '../store'
|
||||
import {
|
||||
useWorkflowRun,
|
||||
} from '../hooks'
|
||||
import Divider from '../../base/divider'
|
||||
import RunningTitle from './running-title'
|
||||
import type { ViewHistoryProps } from './view-history'
|
||||
import ViewHistory from './view-history'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { ArrowNarrowLeft } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
|
||||
export type HeaderInHistoryProps = {
|
||||
viewHistoryProps?: ViewHistoryProps
|
||||
}
|
||||
const HeaderInHistory = ({
|
||||
viewHistoryProps,
|
||||
}: HeaderInHistoryProps) => {
|
||||
const { t } = useTranslation()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const {
|
||||
handleLoadBackupDraft,
|
||||
} = useWorkflowRun()
|
||||
|
||||
const handleGoBackToEdit = useCallback(() => {
|
||||
handleLoadBackupDraft()
|
||||
workflowStore.setState({ historyWorkflowData: undefined })
|
||||
}, [workflowStore, handleLoadBackupDraft])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<RunningTitle />
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<ViewHistory {...viewHistoryProps} withText />
|
||||
<Divider type='vertical' className='mx-auto h-3.5' />
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleGoBackToEdit}
|
||||
>
|
||||
<ArrowNarrowLeft className='mr-1 h-4 w-4' />
|
||||
{t('workflow.common.goBackToEdit')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HeaderInHistory
|
||||
69
dify/web/app/components/workflow/header/index.tsx
Normal file
69
dify/web/app/components/workflow/header/index.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
useWorkflowMode,
|
||||
} from '../hooks'
|
||||
import type { HeaderInNormalProps } from './header-in-normal'
|
||||
import HeaderInNormal from './header-in-normal'
|
||||
import type { HeaderInHistoryProps } from './header-in-view-history'
|
||||
import type { HeaderInRestoringProps } from './header-in-restoring'
|
||||
import { useStore } from '../store'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const HeaderInHistory = dynamic(() => import('./header-in-view-history'), {
|
||||
ssr: false,
|
||||
})
|
||||
const HeaderInRestoring = dynamic(() => import('./header-in-restoring'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export type HeaderProps = {
|
||||
normal?: HeaderInNormalProps
|
||||
viewHistory?: HeaderInHistoryProps
|
||||
restoring?: HeaderInRestoringProps
|
||||
}
|
||||
const Header = ({
|
||||
normal: normalProps,
|
||||
viewHistory: viewHistoryProps,
|
||||
restoring: restoringProps,
|
||||
}: HeaderProps) => {
|
||||
const pathname = usePathname()
|
||||
const inWorkflowCanvas = pathname.endsWith('/workflow')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
const {
|
||||
normal,
|
||||
restoring,
|
||||
viewHistory,
|
||||
} = useWorkflowMode()
|
||||
const maximizeCanvas = useStore(s => s.maximizeCanvas)
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute left-0 top-7 z-10 flex h-0 w-full items-center justify-between bg-mask-top2bottom-gray-50-to-transparent px-3'
|
||||
>
|
||||
{(inWorkflowCanvas || isPipelineCanvas) && maximizeCanvas && <div className='h-14 w-[52px]' />}
|
||||
{
|
||||
normal && (
|
||||
<HeaderInNormal
|
||||
{...normalProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
viewHistory && (
|
||||
<HeaderInHistory
|
||||
{...viewHistoryProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
restoring && (
|
||||
<HeaderInRestoring
|
||||
{...restoringProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Header
|
||||
49
dify/web/app/components/workflow/header/restoring-title.tsx
Normal file
49
dify/web/app/components/workflow/header/restoring-title.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { memo, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { useStore } from '../store'
|
||||
import { WorkflowVersion } from '../types'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
|
||||
const RestoringTitle = () => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const { formatTime } = useTimestamp()
|
||||
const currentVersion = useStore(state => state.currentVersion)
|
||||
const isDraft = currentVersion?.version === WorkflowVersion.Draft
|
||||
const publishStatus = isDraft ? t('workflow.common.unpublished') : t('workflow.common.published')
|
||||
|
||||
const versionName = useMemo(() => {
|
||||
if (isDraft)
|
||||
return t('workflow.versionHistory.currentDraft')
|
||||
return currentVersion?.marked_name || t('workflow.versionHistory.defaultName')
|
||||
}, [currentVersion, t, isDraft])
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-y-0.5'>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<span className='system-sm-semibold text-text-primary'>
|
||||
{versionName}
|
||||
</span>
|
||||
<span className='system-2xs-medium-uppercase rounded-[5px] border border-text-accent-secondary bg-components-badge-bg-dimm px-1 py-0.5 text-text-accent-secondary'>
|
||||
{t('workflow.common.viewOnly')}
|
||||
</span>
|
||||
</div>
|
||||
<div className='system-xs-regular flex h-4 items-center gap-x-1 text-text-tertiary'>
|
||||
{
|
||||
currentVersion && (
|
||||
<>
|
||||
<span>{publishStatus}</span>
|
||||
<span>·</span>
|
||||
<span>{`${formatTimeFromNow((isDraft ? currentVersion.updated_at : currentVersion.created_at) * 1000)} ${formatTime(currentVersion.created_at, 'HH:mm:ss')}`}</span>
|
||||
<span>·</span>
|
||||
<span>{currentVersion?.created_by?.name || ''}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(RestoringTitle)
|
||||
75
dify/web/app/components/workflow/header/run-and-history.tsx
Normal file
75
dify/web/app/components/workflow/header/run-and-history.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiPlayLargeLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useNodesReadOnly,
|
||||
useWorkflowStartRun,
|
||||
} from '../hooks'
|
||||
import type { ViewHistoryProps } from './view-history'
|
||||
import ViewHistory from './view-history'
|
||||
import Checklist from './checklist'
|
||||
import cn from '@/utils/classnames'
|
||||
import RunMode from './run-mode'
|
||||
|
||||
const PreviewMode = memo(() => {
|
||||
const { t } = useTranslation()
|
||||
const { handleWorkflowStartRunInChatflow } = useWorkflowStartRun()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-7 items-center rounded-md px-2.5 text-[13px] font-medium text-components-button-secondary-accent-text',
|
||||
'cursor-pointer hover:bg-state-accent-hover',
|
||||
)}
|
||||
onClick={() => handleWorkflowStartRunInChatflow()}
|
||||
>
|
||||
<RiPlayLargeLine className='mr-1 h-4 w-4' />
|
||||
{t('workflow.common.debugAndPreview')}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export type RunAndHistoryProps = {
|
||||
showRunButton?: boolean
|
||||
runButtonText?: string
|
||||
isRunning?: boolean
|
||||
showPreviewButton?: boolean
|
||||
viewHistoryProps?: ViewHistoryProps
|
||||
components?: {
|
||||
RunMode?: React.ComponentType<
|
||||
{
|
||||
text?: string
|
||||
}
|
||||
>
|
||||
}
|
||||
}
|
||||
const RunAndHistory = ({
|
||||
showRunButton,
|
||||
runButtonText,
|
||||
showPreviewButton,
|
||||
viewHistoryProps,
|
||||
components,
|
||||
}: RunAndHistoryProps) => {
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { RunMode: CustomRunMode } = components || {}
|
||||
|
||||
return (
|
||||
<div className='flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-0.5 shadow-xs'>
|
||||
{
|
||||
showRunButton && (
|
||||
CustomRunMode ? <CustomRunMode text={runButtonText} /> : <RunMode text={runButtonText} />
|
||||
)
|
||||
}
|
||||
{
|
||||
showPreviewButton && <PreviewMode />
|
||||
}
|
||||
<div className='mx-0.5 h-3.5 w-[1px] bg-divider-regular'></div>
|
||||
<ViewHistory {...viewHistoryProps} />
|
||||
<Checklist disabled={nodesReadOnly} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(RunAndHistory)
|
||||
167
dify/web/app/components/workflow/header/run-mode.tsx
Normal file
167
dify/web/app/components/workflow/header/run-mode.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useWorkflowRun, useWorkflowRunValidation, useWorkflowStartRun } from '@/app/components/workflow/hooks'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { EVENT_WORKFLOW_STOP } from '@/app/components/workflow/variable-inspect/types'
|
||||
import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiLoader2Line, RiPlayLargeLine } from '@remixicon/react'
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/line/mediaAndDevices'
|
||||
import { useDynamicTestRunOptions } from '../hooks/use-dynamic-test-run-options'
|
||||
import TestRunMenu, { type TestRunMenuRef, type TriggerOption, TriggerType } from './test-run-menu'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
|
||||
type RunModeProps = {
|
||||
text?: string
|
||||
}
|
||||
|
||||
const RunMode = ({
|
||||
text,
|
||||
}: RunModeProps) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
handleWorkflowTriggerScheduleRunInWorkflow,
|
||||
handleWorkflowTriggerWebhookRunInWorkflow,
|
||||
handleWorkflowTriggerPluginRunInWorkflow,
|
||||
handleWorkflowRunAllTriggersInWorkflow,
|
||||
} = useWorkflowStartRun()
|
||||
const { handleStopRun } = useWorkflowRun()
|
||||
const { validateBeforeRun, warningNodes } = useWorkflowRunValidation()
|
||||
const workflowRunningData = useStore(s => s.workflowRunningData)
|
||||
const isListening = useStore(s => s.isListening)
|
||||
|
||||
const status = workflowRunningData?.result.status
|
||||
const isRunning = status === WorkflowRunningStatus.Running || isListening
|
||||
|
||||
const dynamicOptions = useDynamicTestRunOptions()
|
||||
const testRunMenuRef = useRef<TestRunMenuRef>(null)
|
||||
const { notify } = useToastContext()
|
||||
|
||||
useEffect(() => {
|
||||
// @ts-expect-error - Dynamic property for backward compatibility with keyboard shortcuts
|
||||
window._toggleTestRunDropdown = () => {
|
||||
testRunMenuRef.current?.toggle()
|
||||
}
|
||||
return () => {
|
||||
// @ts-expect-error - Dynamic property cleanup
|
||||
delete window._toggleTestRunDropdown
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
handleStopRun(workflowRunningData?.task_id || '')
|
||||
}, [handleStopRun, workflowRunningData?.task_id])
|
||||
|
||||
const handleTriggerSelect = useCallback((option: TriggerOption) => {
|
||||
// Validate checklist before running any workflow
|
||||
let isValid: boolean = true
|
||||
warningNodes.forEach((node) => {
|
||||
if (node.id === option.nodeId)
|
||||
isValid = false
|
||||
})
|
||||
if (!isValid) {
|
||||
notify({ type: 'error', message: t('workflow.panel.checklistTip') })
|
||||
return
|
||||
}
|
||||
|
||||
if (option.type === TriggerType.UserInput) {
|
||||
handleWorkflowStartRunInWorkflow()
|
||||
}
|
||||
else if (option.type === TriggerType.Schedule) {
|
||||
handleWorkflowTriggerScheduleRunInWorkflow(option.nodeId)
|
||||
}
|
||||
else if (option.type === TriggerType.Webhook) {
|
||||
if (option.nodeId)
|
||||
handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: option.nodeId })
|
||||
}
|
||||
else if (option.type === TriggerType.Plugin) {
|
||||
if (option.nodeId)
|
||||
handleWorkflowTriggerPluginRunInWorkflow(option.nodeId)
|
||||
}
|
||||
else if (option.type === TriggerType.All) {
|
||||
const targetNodeIds = option.relatedNodeIds?.filter(Boolean)
|
||||
if (targetNodeIds && targetNodeIds.length > 0)
|
||||
handleWorkflowRunAllTriggersInWorkflow(targetNodeIds)
|
||||
}
|
||||
else {
|
||||
// Placeholder for trigger-specific execution logic for schedule, webhook, plugin types
|
||||
console.log('TODO: Handle trigger execution for type:', option.type, 'nodeId:', option.nodeId)
|
||||
}
|
||||
}, [
|
||||
validateBeforeRun,
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
handleWorkflowTriggerScheduleRunInWorkflow,
|
||||
handleWorkflowTriggerWebhookRunInWorkflow,
|
||||
handleWorkflowTriggerPluginRunInWorkflow,
|
||||
handleWorkflowRunAllTriggersInWorkflow,
|
||||
])
|
||||
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === EVENT_WORKFLOW_STOP)
|
||||
handleStop()
|
||||
})
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-px'>
|
||||
{
|
||||
isRunning
|
||||
? (
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'system-xs-medium flex h-7 cursor-not-allowed items-center gap-x-1 rounded-l-md bg-state-accent-hover px-1.5 text-text-accent',
|
||||
)}
|
||||
disabled={true}
|
||||
>
|
||||
<RiLoader2Line className='mr-1 size-4 animate-spin' />
|
||||
{isListening ? t('workflow.common.listening') : t('workflow.common.running')}
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<TestRunMenu
|
||||
ref={testRunMenuRef}
|
||||
options={dynamicOptions}
|
||||
onSelect={handleTriggerSelect}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-medium flex h-7 cursor-pointer items-center gap-x-1 rounded-md px-1.5 text-text-accent hover:bg-state-accent-hover',
|
||||
)}
|
||||
style={{ userSelect: 'none' }}
|
||||
>
|
||||
<RiPlayLargeLine className='mr-1 size-4' />
|
||||
{text ?? t('workflow.common.run')}
|
||||
<div className='system-kbd flex items-center gap-x-0.5 text-text-tertiary'>
|
||||
<div className='flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray'>
|
||||
{getKeyboardKeyNameBySystem('alt')}
|
||||
</div>
|
||||
<div className='flex size-4 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray'>
|
||||
R
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TestRunMenu>
|
||||
)
|
||||
}
|
||||
{
|
||||
isRunning && (
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'flex size-7 items-center justify-center rounded-r-md bg-state-accent-active',
|
||||
)}
|
||||
onClick={handleStop}
|
||||
>
|
||||
<StopCircle className='size-4 text-text-accent' />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(RunMode)
|
||||
25
dify/web/app/components/workflow/header/running-title.tsx
Normal file
25
dify/web/app/components/workflow/header/running-title.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { memo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useIsChatMode } from '../hooks'
|
||||
import { useStore } from '../store'
|
||||
import { formatWorkflowRunIdentifier } from '../utils'
|
||||
import { ClockPlay } from '@/app/components/base/icons/src/vender/line/time'
|
||||
|
||||
const RunningTitle = () => {
|
||||
const { t } = useTranslation()
|
||||
const isChatMode = useIsChatMode()
|
||||
const historyWorkflowData = useStore(s => s.historyWorkflowData)
|
||||
|
||||
return (
|
||||
<div className='flex h-[18px] items-center text-xs text-gray-500'>
|
||||
<ClockPlay className='mr-1 h-3 w-3 text-gray-500' />
|
||||
<span>{isChatMode ? `Test Chat${formatWorkflowRunIdentifier(historyWorkflowData?.finished_at)}` : `Test Run${formatWorkflowRunIdentifier(historyWorkflowData?.finished_at)}`}</span>
|
||||
<span className='mx-1'>·</span>
|
||||
<span className='ml-1 flex h-[18px] items-center rounded-[5px] border border-indigo-300 bg-white/[0.48] px-1 text-[10px] font-semibold uppercase text-indigo-600'>
|
||||
{t('workflow.common.viewOnly')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(RunningTitle)
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { FC } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { CommonNodeType } from '../types'
|
||||
import { scrollToWorkflowNode } from '../utils/node-navigation'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const ScrollToSelectedNodeButton: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes<CommonNodeType>()
|
||||
const selectedNode = nodes.find(node => node.data.selected)
|
||||
|
||||
const handleScrollToSelectedNode = useCallback(() => {
|
||||
if (!selectedNode) return
|
||||
scrollToWorkflowNode(selectedNode.id)
|
||||
}, [selectedNode])
|
||||
|
||||
if (!selectedNode)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-medium flex h-6 cursor-pointer items-center justify-center whitespace-nowrap rounded-md border-[0.5px] border-effects-highlight bg-components-actionbar-bg px-3 text-text-tertiary shadow-lg backdrop-blur-sm transition-colors duration-200 hover:text-text-accent',
|
||||
)}
|
||||
onClick={handleScrollToSelectedNode}
|
||||
>
|
||||
{t('workflow.panel.scrollToSelectedNode')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ScrollToSelectedNodeButton
|
||||
251
dify/web/app/components/workflow/header/test-run-menu.tsx
Normal file
251
dify/web/app/components/workflow/header/test-run-menu.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import {
|
||||
type MouseEvent,
|
||||
type MouseEventHandler,
|
||||
type ReactElement,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import ShortcutsName from '../shortcuts-name'
|
||||
|
||||
export enum TriggerType {
|
||||
UserInput = 'user_input',
|
||||
Schedule = 'schedule',
|
||||
Webhook = 'webhook',
|
||||
Plugin = 'plugin',
|
||||
All = 'all',
|
||||
}
|
||||
|
||||
export type TriggerOption = {
|
||||
id: string
|
||||
type: TriggerType
|
||||
name: string
|
||||
icon: React.ReactNode
|
||||
nodeId?: string
|
||||
relatedNodeIds?: string[]
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type TestRunOptions = {
|
||||
userInput?: TriggerOption
|
||||
triggers: TriggerOption[]
|
||||
runAll?: TriggerOption
|
||||
}
|
||||
|
||||
type TestRunMenuProps = {
|
||||
options: TestRunOptions
|
||||
onSelect: (option: TriggerOption) => void
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export type TestRunMenuRef = {
|
||||
toggle: () => void
|
||||
}
|
||||
|
||||
type ShortcutMapping = {
|
||||
option: TriggerOption
|
||||
shortcutKey: string
|
||||
}
|
||||
|
||||
const buildShortcutMappings = (options: TestRunOptions): ShortcutMapping[] => {
|
||||
const mappings: ShortcutMapping[] = []
|
||||
|
||||
if (options.userInput && options.userInput.enabled !== false)
|
||||
mappings.push({ option: options.userInput, shortcutKey: '~' })
|
||||
|
||||
let numericShortcut = 0
|
||||
|
||||
if (options.runAll && options.runAll.enabled !== false)
|
||||
mappings.push({ option: options.runAll, shortcutKey: String(numericShortcut++) })
|
||||
|
||||
options.triggers.forEach((trigger) => {
|
||||
if (trigger.enabled !== false)
|
||||
mappings.push({ option: trigger, shortcutKey: String(numericShortcut++) })
|
||||
})
|
||||
|
||||
return mappings
|
||||
}
|
||||
|
||||
const TestRunMenu = forwardRef<TestRunMenuRef, TestRunMenuProps>(({
|
||||
options,
|
||||
onSelect,
|
||||
children,
|
||||
}, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const shortcutMappings = useMemo(() => buildShortcutMappings(options), [options])
|
||||
const shortcutKeyById = useMemo(() => {
|
||||
const map = new Map<string, string>()
|
||||
shortcutMappings.forEach(({ option, shortcutKey }) => {
|
||||
map.set(option.id, shortcutKey)
|
||||
})
|
||||
return map
|
||||
}, [shortcutMappings])
|
||||
|
||||
const handleSelect = useCallback((option: TriggerOption) => {
|
||||
onSelect(option)
|
||||
setOpen(false)
|
||||
}, [onSelect])
|
||||
|
||||
const enabledOptions = useMemo(() => {
|
||||
const flattened: TriggerOption[] = []
|
||||
|
||||
if (options.userInput)
|
||||
flattened.push(options.userInput)
|
||||
if (options.runAll)
|
||||
flattened.push(options.runAll)
|
||||
flattened.push(...options.triggers)
|
||||
|
||||
return flattened.filter(option => option.enabled !== false)
|
||||
}, [options])
|
||||
|
||||
const hasSingleEnabledOption = enabledOptions.length === 1
|
||||
const soleEnabledOption = hasSingleEnabledOption ? enabledOptions[0] : undefined
|
||||
|
||||
const runSoleOption = useCallback(() => {
|
||||
if (soleEnabledOption)
|
||||
handleSelect(soleEnabledOption)
|
||||
}, [handleSelect, soleEnabledOption])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
toggle: () => {
|
||||
if (hasSingleEnabledOption) {
|
||||
runSoleOption()
|
||||
return
|
||||
}
|
||||
|
||||
setOpen(prev => !prev)
|
||||
},
|
||||
}), [hasSingleEnabledOption, runSoleOption])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.repeat || event.altKey || event.ctrlKey || event.metaKey)
|
||||
return
|
||||
|
||||
const normalizedKey = event.key === '`' ? '~' : event.key
|
||||
const mapping = shortcutMappings.find(({ shortcutKey }) => shortcutKey === normalizedKey)
|
||||
|
||||
if (mapping) {
|
||||
event.preventDefault()
|
||||
handleSelect(mapping.option)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleSelect, open, shortcutMappings])
|
||||
|
||||
const renderOption = (option: TriggerOption) => {
|
||||
const shortcutKey = shortcutKeyById.get(option.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={option.id}
|
||||
className='system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'
|
||||
onClick={() => handleSelect(option)}
|
||||
>
|
||||
<div className='flex min-w-0 flex-1 items-center'>
|
||||
<div className='flex h-6 w-6 shrink-0 items-center justify-center'>
|
||||
{option.icon}
|
||||
</div>
|
||||
<span className='ml-2 truncate'>{option.name}</span>
|
||||
</div>
|
||||
{shortcutKey && (
|
||||
<ShortcutsName keys={[shortcutKey]} className="ml-2" textColor="secondary" />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const hasUserInput = !!options.userInput && options.userInput.enabled !== false
|
||||
const hasTriggers = options.triggers.some(trigger => trigger.enabled !== false)
|
||||
const hasRunAll = !!options.runAll && options.runAll.enabled !== false
|
||||
|
||||
if (hasSingleEnabledOption && soleEnabledOption) {
|
||||
const handleRunClick = (event?: MouseEvent<HTMLElement>) => {
|
||||
if (event?.defaultPrevented)
|
||||
return
|
||||
|
||||
runSoleOption()
|
||||
}
|
||||
|
||||
if (isValidElement(children)) {
|
||||
const childElement = children as ReactElement<{ onClick?: MouseEventHandler<HTMLElement> }>
|
||||
const originalOnClick = childElement.props?.onClick
|
||||
|
||||
return cloneElement(childElement, {
|
||||
onClick: (event: MouseEvent<HTMLElement>) => {
|
||||
if (typeof originalOnClick === 'function')
|
||||
originalOnClick(event)
|
||||
|
||||
if (event?.defaultPrevented)
|
||||
return
|
||||
|
||||
runSoleOption()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<span onClick={handleRunClick}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={{ mainAxis: 8, crossAxis: -4 }}
|
||||
>
|
||||
<PortalToFollowElemTrigger asChild onClick={() => setOpen(!open)}>
|
||||
<div style={{ userSelect: 'none' }}>
|
||||
{children}
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[12]'>
|
||||
<div className='w-[284px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg'>
|
||||
<div className='mb-2 px-3 pt-2 text-sm font-medium text-text-primary'>
|
||||
{t('workflow.common.chooseStartNodeToRun')}
|
||||
</div>
|
||||
<div>
|
||||
{hasUserInput && renderOption(options.userInput!)}
|
||||
|
||||
{(hasTriggers || hasRunAll) && hasUserInput && (
|
||||
<div className='mx-3 my-1 h-px bg-divider-subtle' />
|
||||
)}
|
||||
|
||||
{hasRunAll && renderOption(options.runAll!)}
|
||||
|
||||
{hasTriggers && options.triggers
|
||||
.filter(trigger => trigger.enabled !== false)
|
||||
.map(trigger => renderOption(trigger))}
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
})
|
||||
|
||||
TestRunMenu.displayName = 'TestRunMenu'
|
||||
|
||||
export default TestRunMenu
|
||||
66
dify/web/app/components/workflow/header/undo-redo.tsx
Normal file
66
dify/web/app/components/workflow/header/undo-redo.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { FC } from 'react'
|
||||
import { memo, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiArrowGoBackLine,
|
||||
RiArrowGoForwardFill,
|
||||
} from '@remixicon/react'
|
||||
import TipPopup from '../operator/tip-popup'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
import Divider from '../../base/divider'
|
||||
import { useNodesReadOnly } from '@/app/components/workflow/hooks'
|
||||
import ViewWorkflowHistory from '@/app/components/workflow/header/view-workflow-history'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
export type UndoRedoProps = { handleUndo: () => void; handleRedo: () => void }
|
||||
const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
||||
const { t } = useTranslation()
|
||||
const { store } = useWorkflowHistoryStore()
|
||||
const [buttonsDisabled, setButtonsDisabled] = useState({ undo: true, redo: true })
|
||||
|
||||
useEffect(() => {
|
||||
const unsubscribe = store.temporal.subscribe((state) => {
|
||||
setButtonsDisabled({
|
||||
undo: state.pastStates.length === 0,
|
||||
redo: state.futureStates.length === 0,
|
||||
})
|
||||
})
|
||||
return () => unsubscribe()
|
||||
}, [store])
|
||||
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
|
||||
return (
|
||||
<div className='flex items-center space-x-0.5 rounded-lg border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-lg backdrop-blur-[5px]'>
|
||||
<TipPopup title={t('workflow.common.undo')!} shortcuts={['ctrl', 'z']}>
|
||||
<div
|
||||
data-tooltip-id='workflow.undo'
|
||||
className={
|
||||
classNames('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
(nodesReadOnly || buttonsDisabled.undo)
|
||||
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')}
|
||||
onClick={() => !nodesReadOnly && !buttonsDisabled.undo && handleUndo()}
|
||||
>
|
||||
<RiArrowGoBackLine className='h-4 w-4' />
|
||||
</div>
|
||||
</TipPopup >
|
||||
<TipPopup title={t('workflow.common.redo')!} shortcuts={['ctrl', 'y']}>
|
||||
<div
|
||||
data-tooltip-id='workflow.redo'
|
||||
className={
|
||||
classNames('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
(nodesReadOnly || buttonsDisabled.redo)
|
||||
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
|
||||
)}
|
||||
onClick={() => !nodesReadOnly && !buttonsDisabled.redo && handleRedo()}
|
||||
>
|
||||
<RiArrowGoForwardFill className='h-4 w-4' />
|
||||
</div>
|
||||
</TipPopup>
|
||||
<Divider type='vertical' className="mx-0.5 h-3.5" />
|
||||
<ViewWorkflowHistory />
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(UndoRedo)
|
||||
@@ -0,0 +1,71 @@
|
||||
import React, { type FC, useCallback } from 'react'
|
||||
import { RiHistoryLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import Button from '../../base/button'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../utils'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type VersionHistoryButtonProps = {
|
||||
onClick: () => Promise<unknown> | unknown
|
||||
}
|
||||
|
||||
const VERSION_HISTORY_SHORTCUT = ['ctrl', '⇧', 'H']
|
||||
|
||||
const PopupContent = React.memo(() => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<div className='system-xs-medium px-0.5 text-text-secondary'>
|
||||
{t('workflow.common.versionHistory')}
|
||||
</div>
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
{VERSION_HISTORY_SHORTCUT.map(key => (
|
||||
<span
|
||||
key={key}
|
||||
className='system-kbd rounded-[4px] bg-components-kbd-bg-white px-[1px] text-text-tertiary'
|
||||
>
|
||||
{getKeyboardKeyNameBySystem(key)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
PopupContent.displayName = 'PopupContent'
|
||||
|
||||
const VersionHistoryButton: FC<VersionHistoryButtonProps> = ({
|
||||
onClick,
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
const handleViewVersionHistory = useCallback(async () => {
|
||||
await onClick?.()
|
||||
}, [onClick])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.h`, (e) => {
|
||||
e.preventDefault()
|
||||
handleViewVersionHistory()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
return <Tooltip
|
||||
popupContent={<PopupContent />}
|
||||
noDecoration
|
||||
popupClassName='rounded-lg border-[0.5px] border-components-panel-border bg-components-tooltip-bg
|
||||
shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px] p-1.5'
|
||||
>
|
||||
<Button
|
||||
className={cn(
|
||||
'p-2',
|
||||
theme === 'dark' && 'rounded-lg border border-black/5 bg-white/10 backdrop-blur-sm',
|
||||
)}
|
||||
onClick={handleViewVersionHistory}
|
||||
>
|
||||
<RiHistoryLine className='h-4 w-4 text-components-button-secondary-text' />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
}
|
||||
|
||||
export default VersionHistoryButton
|
||||
222
dify/web/app/components/workflow/header/view-history.tsx
Normal file
222
dify/web/app/components/workflow/header/view-history.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { Fetcher } from 'swr'
|
||||
import useSWR from 'swr'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { noop } from 'lodash-es'
|
||||
import {
|
||||
RiCheckboxCircleLine,
|
||||
RiCloseLine,
|
||||
RiErrorWarningLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useIsChatMode,
|
||||
useNodesInteractions,
|
||||
useWorkflowInteractions,
|
||||
useWorkflowRun,
|
||||
} from '../hooks'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { ControlMode, WorkflowRunningStatus } from '../types'
|
||||
import { formatWorkflowRunIdentifier } from '../utils'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
ClockPlay,
|
||||
ClockPlaySlim,
|
||||
} from '@/app/components/base/icons/src/vender/line/time'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from '@/app/components/workflow/store'
|
||||
import type { WorkflowRunHistoryResponse } from '@/types/workflow'
|
||||
import { useInputFieldPanel } from '@/app/components/rag-pipeline/hooks'
|
||||
|
||||
export type ViewHistoryProps = {
|
||||
withText?: boolean
|
||||
onClearLogAndMessageModal?: () => void
|
||||
historyUrl?: string
|
||||
historyFetcher?: Fetcher<WorkflowRunHistoryResponse, string>
|
||||
}
|
||||
const ViewHistory = ({
|
||||
withText,
|
||||
onClearLogAndMessageModal,
|
||||
historyUrl,
|
||||
historyFetcher,
|
||||
}: ViewHistoryProps) => {
|
||||
const { t } = useTranslation()
|
||||
const isChatMode = useIsChatMode()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const {
|
||||
handleNodesCancelSelected,
|
||||
} = useNodesInteractions()
|
||||
const {
|
||||
handleCancelDebugAndPreviewPanel,
|
||||
} = useWorkflowInteractions()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const setControlMode = useStore(s => s.setControlMode)
|
||||
const historyWorkflowData = useStore(s => s.historyWorkflowData)
|
||||
const { handleBackupDraft } = useWorkflowRun()
|
||||
const { closeAllInputFieldPanels } = useInputFieldPanel()
|
||||
|
||||
const fetcher = historyFetcher ?? (noop as Fetcher<WorkflowRunHistoryResponse, string>)
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
} = useSWR((open && historyUrl && historyFetcher) ? historyUrl : null, fetcher)
|
||||
|
||||
return (
|
||||
(
|
||||
<PortalToFollowElem
|
||||
placement={withText ? 'bottom-start' : 'bottom-end'}
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: withText ? -8 : 10,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
{
|
||||
withText && (
|
||||
<div className={cn(
|
||||
'flex h-8 items-center rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3 shadow-xs',
|
||||
'cursor-pointer text-[13px] font-medium text-components-button-secondary-text hover:bg-components-button-secondary-bg-hover',
|
||||
open && 'bg-components-button-secondary-bg-hover',
|
||||
)}>
|
||||
<ClockPlay
|
||||
className={'mr-1 h-4 w-4'}
|
||||
/>
|
||||
{t('workflow.common.showRunHistory')}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!withText && (
|
||||
<Tooltip
|
||||
popupContent={t('workflow.common.viewRunHistory')}
|
||||
>
|
||||
<div
|
||||
className={cn('group flex h-7 w-7 cursor-pointer items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
|
||||
onClick={() => {
|
||||
onClearLogAndMessageModal?.()
|
||||
}}
|
||||
>
|
||||
<ClockPlay className={cn('h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[12]'>
|
||||
<div
|
||||
className='ml-2 flex w-[240px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'
|
||||
style={{
|
||||
maxHeight: 'calc(2 / 3 * 100vh)',
|
||||
}}
|
||||
>
|
||||
<div className='sticky top-0 flex items-center justify-between bg-components-panel-bg px-4 pt-3 text-base font-semibold text-text-primary'>
|
||||
<div className='grow'>{t('workflow.common.runHistory')}</div>
|
||||
<div
|
||||
className='flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center'
|
||||
onClick={() => {
|
||||
onClearLogAndMessageModal?.()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
isLoading && (
|
||||
<div className='flex h-10 items-center justify-center'>
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!isLoading && (
|
||||
<div className='p-2'>
|
||||
{
|
||||
!data?.data.length && (
|
||||
<div className='py-12'>
|
||||
<ClockPlaySlim className='mx-auto mb-2 h-8 w-8 text-text-quaternary' />
|
||||
<div className='text-center text-[13px] text-text-quaternary'>
|
||||
{t('workflow.common.notRunning')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
data?.data.map(item => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'mb-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] hover:bg-state-base-hover',
|
||||
item.id === historyWorkflowData?.id && 'bg-state-accent-hover hover:bg-state-accent-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
workflowStore.setState({
|
||||
historyWorkflowData: item,
|
||||
showInputsPanel: false,
|
||||
showEnvPanel: false,
|
||||
})
|
||||
closeAllInputFieldPanels()
|
||||
handleBackupDraft()
|
||||
setOpen(false)
|
||||
handleNodesCancelSelected()
|
||||
handleCancelDebugAndPreviewPanel()
|
||||
setControlMode(ControlMode.Hand)
|
||||
}}
|
||||
>
|
||||
{
|
||||
!isChatMode && item.status === WorkflowRunningStatus.Stopped && (
|
||||
<AlertTriangle className='mr-1.5 mt-0.5 h-3.5 w-3.5 text-[#F79009]' />
|
||||
)
|
||||
}
|
||||
{
|
||||
!isChatMode && item.status === WorkflowRunningStatus.Failed && (
|
||||
<RiErrorWarningLine className='mr-1.5 mt-0.5 h-3.5 w-3.5 text-[#F04438]' />
|
||||
)
|
||||
}
|
||||
{
|
||||
!isChatMode && item.status === WorkflowRunningStatus.Succeeded && (
|
||||
<RiCheckboxCircleLine className='mr-1.5 mt-0.5 h-3.5 w-3.5 text-[#12B76A]' />
|
||||
)
|
||||
}
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center text-[13px] font-medium leading-[18px] text-text-primary',
|
||||
item.id === historyWorkflowData?.id && 'text-text-accent',
|
||||
)}
|
||||
>
|
||||
{`Test ${isChatMode ? 'Chat' : 'Run'}${formatWorkflowRunIdentifier(item.finished_at)}`}
|
||||
</div>
|
||||
<div className='flex items-center text-xs leading-[18px] text-text-tertiary'>
|
||||
{item.created_by_account?.name} · {formatTimeFromNow((item.finished_at || item.created_at) * 1000)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ViewHistory)
|
||||
@@ -0,0 +1,294 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import {
|
||||
RiCloseLine,
|
||||
RiHistoryLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import {
|
||||
useNodesReadOnly,
|
||||
useWorkflowHistory,
|
||||
} from '../hooks'
|
||||
import TipPopup from '../operator/tip-popup'
|
||||
import type { WorkflowHistoryState } from '../workflow-history-store'
|
||||
import Divider from '../../base/divider'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
type ChangeHistoryEntry = {
|
||||
label: string
|
||||
index: number
|
||||
state: Partial<WorkflowHistoryState>
|
||||
}
|
||||
|
||||
type ChangeHistoryList = {
|
||||
pastStates: ChangeHistoryEntry[]
|
||||
futureStates: ChangeHistoryEntry[]
|
||||
statesCount: number
|
||||
}
|
||||
|
||||
const ViewWorkflowHistory = () => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const { nodesReadOnly } = useNodesReadOnly()
|
||||
const { setCurrentLogItem, setShowMessageLogModal } = useAppStore(useShallow(state => ({
|
||||
appDetail: state.appDetail,
|
||||
setCurrentLogItem: state.setCurrentLogItem,
|
||||
setShowMessageLogModal: state.setShowMessageLogModal,
|
||||
})))
|
||||
const reactFlowStore = useStoreApi()
|
||||
const { store, getHistoryLabel } = useWorkflowHistory()
|
||||
|
||||
const { pastStates, futureStates, undo, redo, clear } = store.temporal.getState()
|
||||
const [currentHistoryStateIndex, setCurrentHistoryStateIndex] = useState<number>(0)
|
||||
|
||||
const handleClearHistory = useCallback(() => {
|
||||
clear()
|
||||
setCurrentHistoryStateIndex(0)
|
||||
}, [clear])
|
||||
|
||||
const handleSetState = useCallback(({ index }: ChangeHistoryEntry) => {
|
||||
const { setEdges, setNodes } = reactFlowStore.getState()
|
||||
const diff = currentHistoryStateIndex + index
|
||||
if (diff === 0)
|
||||
return
|
||||
|
||||
if (diff < 0)
|
||||
undo(diff * -1)
|
||||
else
|
||||
redo(diff)
|
||||
|
||||
const { edges, nodes } = store.getState()
|
||||
if (edges.length === 0 && nodes.length === 0)
|
||||
return
|
||||
|
||||
setEdges(edges)
|
||||
setNodes(nodes)
|
||||
}, [currentHistoryStateIndex, reactFlowStore, redo, store, undo])
|
||||
|
||||
const calculateStepLabel = useCallback((index: number) => {
|
||||
if (!index)
|
||||
return
|
||||
|
||||
const count = index < 0 ? index * -1 : index
|
||||
return `${index > 0 ? t('workflow.changeHistory.stepForward', { count }) : t('workflow.changeHistory.stepBackward', { count })}`
|
||||
}, [t])
|
||||
|
||||
const calculateChangeList: ChangeHistoryList = useMemo(() => {
|
||||
const filterList = (list: any, startIndex = 0, reverse = false) => list.map((state: Partial<WorkflowHistoryState>, index: number) => {
|
||||
const nodes = (state.nodes || store.getState().nodes) || []
|
||||
const nodeId = state?.workflowHistoryEventMeta?.nodeId
|
||||
const targetTitle = nodes.find(n => n.id === nodeId)?.data?.title ?? ''
|
||||
return {
|
||||
label: state.workflowHistoryEvent && getHistoryLabel(state.workflowHistoryEvent),
|
||||
index: reverse ? list.length - 1 - index - startIndex : index - startIndex,
|
||||
state: {
|
||||
...state,
|
||||
workflowHistoryEventMeta: state.workflowHistoryEventMeta ? {
|
||||
...state.workflowHistoryEventMeta,
|
||||
nodeTitle: state.workflowHistoryEventMeta.nodeTitle || targetTitle,
|
||||
} : undefined,
|
||||
},
|
||||
}
|
||||
}).filter(Boolean)
|
||||
|
||||
const historyData = {
|
||||
pastStates: filterList(pastStates, pastStates.length).reverse(),
|
||||
futureStates: filterList([...futureStates, (!pastStates.length && !futureStates.length) ? undefined : store.getState()].filter(Boolean), 0, true),
|
||||
statesCount: 0,
|
||||
}
|
||||
|
||||
historyData.statesCount = pastStates.length + futureStates.length
|
||||
|
||||
return {
|
||||
...historyData,
|
||||
statesCount: pastStates.length + futureStates.length,
|
||||
}
|
||||
}, [futureStates, getHistoryLabel, pastStates, store])
|
||||
|
||||
const composeHistoryItemLabel = useCallback((nodeTitle: string | undefined, baseLabel: string) => {
|
||||
if (!nodeTitle)
|
||||
return baseLabel
|
||||
return `${nodeTitle} ${baseLabel}`
|
||||
}, [])
|
||||
|
||||
return (
|
||||
(
|
||||
<PortalToFollowElem
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: 131,
|
||||
}}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => !nodesReadOnly && setOpen(v => !v)}>
|
||||
<TipPopup
|
||||
title={t('workflow.changeHistory.title')}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
classNames('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
open && 'bg-state-accent-active text-text-accent',
|
||||
nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (nodesReadOnly)
|
||||
return
|
||||
setCurrentLogItem()
|
||||
setShowMessageLogModal(false)
|
||||
}}
|
||||
>
|
||||
<RiHistoryLine className='h-4 w-4' />
|
||||
</div>
|
||||
</TipPopup>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[12]'>
|
||||
<div
|
||||
className='ml-2 flex min-w-[240px] max-w-[360px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-xl backdrop-blur-[5px]'
|
||||
>
|
||||
<div className='sticky top-0 flex items-center justify-between px-4 pt-3'>
|
||||
<div className='system-mg-regular grow text-text-secondary'>{t('workflow.changeHistory.title')}</div>
|
||||
<div
|
||||
className='flex h-6 w-6 shrink-0 cursor-pointer items-center justify-center'
|
||||
onClick={() => {
|
||||
setCurrentLogItem()
|
||||
setShowMessageLogModal(false)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-text-secondary' />
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
(
|
||||
<div
|
||||
className='overflow-y-auto p-2'
|
||||
style={{
|
||||
maxHeight: 'calc(1 / 2 * 100vh)',
|
||||
}}
|
||||
>
|
||||
{
|
||||
!calculateChangeList.statesCount && (
|
||||
<div className='py-12'>
|
||||
<RiHistoryLine className='mx-auto mb-2 h-8 w-8 text-text-tertiary' />
|
||||
<div className='text-center text-[13px] text-text-tertiary'>
|
||||
{t('workflow.changeHistory.placeholder')}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className='flex flex-col'>
|
||||
{
|
||||
calculateChangeList.futureStates.map((item: ChangeHistoryEntry) => (
|
||||
<div
|
||||
key={item?.index}
|
||||
className={cn(
|
||||
'mb-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] text-text-secondary hover:bg-state-base-hover',
|
||||
item?.index === currentHistoryStateIndex && 'bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
handleSetState(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center text-[13px] font-medium leading-[18px] text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{composeHistoryItemLabel(
|
||||
item?.state?.workflowHistoryEventMeta?.nodeTitle,
|
||||
item?.label || t('workflow.changeHistory.sessionStart'),
|
||||
)} ({calculateStepLabel(item?.index)}{item?.index === currentHistoryStateIndex && t('workflow.changeHistory.currentState')})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
{
|
||||
calculateChangeList.pastStates.map((item: ChangeHistoryEntry) => (
|
||||
<div
|
||||
key={item?.index}
|
||||
className={cn(
|
||||
'mb-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] hover:bg-state-base-hover',
|
||||
item?.index === calculateChangeList.statesCount - 1 && 'bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
handleSetState(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center text-[13px] font-medium leading-[18px] text-text-secondary',
|
||||
)}
|
||||
>
|
||||
{composeHistoryItemLabel(
|
||||
item?.state?.workflowHistoryEventMeta?.nodeTitle,
|
||||
item?.label || t('workflow.changeHistory.sessionStart'),
|
||||
)} ({calculateStepLabel(item?.index)})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!calculateChangeList.statesCount && (
|
||||
<div className='px-0.5'>
|
||||
<Divider className='m-0' />
|
||||
<div
|
||||
className={cn(
|
||||
'my-0.5 flex cursor-pointer rounded-lg px-2 py-[7px] text-text-secondary',
|
||||
'hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
handleClearHistory()
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center text-[13px] font-medium leading-[18px]',
|
||||
)}
|
||||
>
|
||||
{t('workflow.changeHistory.clearHistory')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="w-[240px] px-3 py-2 text-xs text-text-tertiary" >
|
||||
<div className="mb-1 flex h-[22px] items-center font-medium uppercase">{t('workflow.changeHistory.hint')}</div>
|
||||
<div className="mb-1 leading-[18px] text-text-tertiary">{t('workflow.changeHistory.hintText')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ViewWorkflowHistory)
|
||||
72
dify/web/app/components/workflow/help-line/index.tsx
Normal file
72
dify/web/app/components/workflow/help-line/index.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { memo } from 'react'
|
||||
import { useViewport } from 'reactflow'
|
||||
import { useStore } from '../store'
|
||||
import type {
|
||||
HelpLineHorizontalPosition,
|
||||
HelpLineVerticalPosition,
|
||||
} from './types'
|
||||
|
||||
const HelpLineHorizontal = memo(({
|
||||
top,
|
||||
left,
|
||||
width,
|
||||
}: HelpLineHorizontalPosition) => {
|
||||
const { x, y, zoom } = useViewport()
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute z-[9] h-px bg-primary-300'
|
||||
style={{
|
||||
top: top * zoom + y,
|
||||
left: left * zoom + x,
|
||||
width: width * zoom,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
HelpLineHorizontal.displayName = 'HelpLineBase'
|
||||
|
||||
const HelpLineVertical = memo(({
|
||||
top,
|
||||
left,
|
||||
height,
|
||||
}: HelpLineVerticalPosition) => {
|
||||
const { x, y, zoom } = useViewport()
|
||||
|
||||
return (
|
||||
<div
|
||||
className='absolute z-[9] w-[1px] bg-primary-300'
|
||||
style={{
|
||||
top: top * zoom + y,
|
||||
left: left * zoom + x,
|
||||
height: height * zoom,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
HelpLineVertical.displayName = 'HelpLineVertical'
|
||||
|
||||
const HelpLine = () => {
|
||||
const helpLineHorizontal = useStore(s => s.helpLineHorizontal)
|
||||
const helpLineVertical = useStore(s => s.helpLineVertical)
|
||||
|
||||
if (!helpLineHorizontal && !helpLineVertical)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{
|
||||
helpLineHorizontal && (
|
||||
<HelpLineHorizontal {...helpLineHorizontal} />
|
||||
)
|
||||
}
|
||||
{
|
||||
helpLineVertical && (
|
||||
<HelpLineVertical {...helpLineVertical} />
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(HelpLine)
|
||||
11
dify/web/app/components/workflow/help-line/types.ts
Normal file
11
dify/web/app/components/workflow/help-line/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type HelpLineHorizontalPosition = {
|
||||
top: number
|
||||
left: number
|
||||
width: number
|
||||
}
|
||||
|
||||
export type HelpLineVerticalPosition = {
|
||||
top: number
|
||||
left: number
|
||||
height: number
|
||||
}
|
||||
2
dify/web/app/components/workflow/hooks-store/index.ts
Normal file
2
dify/web/app/components/workflow/hooks-store/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './provider'
|
||||
export * from './store'
|
||||
35
dify/web/app/components/workflow/hooks-store/provider.tsx
Normal file
35
dify/web/app/components/workflow/hooks-store/provider.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
createContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useStore } from 'reactflow'
|
||||
import {
|
||||
createHooksStore,
|
||||
} from './store'
|
||||
import type { Shape } from './store'
|
||||
|
||||
type HooksStore = ReturnType<typeof createHooksStore>
|
||||
export const HooksStoreContext = createContext<HooksStore | null | undefined>(null)
|
||||
type HooksStoreContextProviderProps = Partial<Shape> & {
|
||||
children: React.ReactNode
|
||||
}
|
||||
export const HooksStoreContextProvider = ({ children, ...restProps }: HooksStoreContextProviderProps) => {
|
||||
const storeRef = useRef<HooksStore | undefined>(undefined)
|
||||
const d3Selection = useStore(s => s.d3Selection)
|
||||
const d3Zoom = useStore(s => s.d3Zoom)
|
||||
|
||||
useEffect(() => {
|
||||
if (storeRef.current && d3Selection && d3Zoom)
|
||||
storeRef.current.getState().refreshAll(restProps)
|
||||
}, [d3Selection, d3Zoom])
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createHooksStore(restProps)
|
||||
|
||||
return (
|
||||
<HooksStoreContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</HooksStoreContext.Provider>
|
||||
)
|
||||
}
|
||||
172
dify/web/app/components/workflow/hooks-store/store.ts
Normal file
172
dify/web/app/components/workflow/hooks-store/store.ts
Normal file
@@ -0,0 +1,172 @@
|
||||
import { useContext } from 'react'
|
||||
import {
|
||||
noop,
|
||||
} from 'lodash-es'
|
||||
import {
|
||||
useStore as useZustandStore,
|
||||
} from 'zustand'
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
import { HooksStoreContext } from './provider'
|
||||
import type {
|
||||
BlockEnum,
|
||||
NodeDefault,
|
||||
ToolWithProvider,
|
||||
} from '@/app/components/workflow/types'
|
||||
import type { IOtherOptions } from '@/service/base'
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
import type {
|
||||
Node,
|
||||
ValueSelector,
|
||||
} from '@/app/components/workflow/types'
|
||||
import type { FlowType } from '@/types/common'
|
||||
import type { FileUpload } from '../../base/features/types'
|
||||
import type { SchemaTypeDefinition } from '@/service/use-common'
|
||||
|
||||
export type AvailableNodesMetaData = {
|
||||
nodes: NodeDefault[]
|
||||
nodesMap?: Record<BlockEnum, NodeDefault<any>>
|
||||
}
|
||||
export type CommonHooksFnMap = {
|
||||
doSyncWorkflowDraft: (
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
callback?: {
|
||||
onSuccess?: () => void
|
||||
onError?: () => void
|
||||
onSettled?: () => void,
|
||||
},
|
||||
) => Promise<void>
|
||||
syncWorkflowDraftWhenPageClose: () => void
|
||||
handleRefreshWorkflowDraft: () => void
|
||||
handleBackupDraft: () => void
|
||||
handleLoadBackupDraft: () => void
|
||||
handleRestoreFromPublishedWorkflow: (...args: any[]) => void
|
||||
handleRun: (params: any, callback?: IOtherOptions, options?: any) => void
|
||||
handleStopRun: (...args: any[]) => void
|
||||
handleStartWorkflowRun: () => void
|
||||
handleWorkflowStartRunInWorkflow: () => void
|
||||
handleWorkflowStartRunInChatflow: () => void
|
||||
handleWorkflowTriggerScheduleRunInWorkflow: (nodeId?: string) => void
|
||||
handleWorkflowTriggerWebhookRunInWorkflow: (params: { nodeId: string }) => void
|
||||
handleWorkflowTriggerPluginRunInWorkflow: (nodeId?: string) => void
|
||||
handleWorkflowRunAllTriggersInWorkflow: (nodeIds: string[]) => void
|
||||
availableNodesMetaData?: AvailableNodesMetaData
|
||||
getWorkflowRunAndTraceUrl: (runId?: string) => { runUrl: string; traceUrl: string }
|
||||
exportCheck?: () => Promise<void>
|
||||
handleExportDSL?: (include?: boolean, flowId?: string) => Promise<void>
|
||||
fetchInspectVars: (params: { passInVars?: boolean, vars?: VarInInspect[], passedInAllPluginInfoList?: Record<string, ToolWithProvider[]>, passedInSchemaTypeDefinitions?: SchemaTypeDefinition[] }) => Promise<void>
|
||||
hasNodeInspectVars: (nodeId: string) => boolean
|
||||
hasSetInspectVar: (nodeId: string, name: string, sysVars: VarInInspect[], conversationVars: VarInInspect[]) => boolean
|
||||
fetchInspectVarValue: (selector: ValueSelector, schemaTypeDefinitions: SchemaTypeDefinition[]) => Promise<void>
|
||||
editInspectVarValue: (nodeId: string, varId: string, value: any) => Promise<void>
|
||||
renameInspectVarName: (nodeId: string, oldName: string, newName: string) => Promise<void>
|
||||
appendNodeInspectVars: (nodeId: string, payload: VarInInspect[], allNodes: Node[]) => void
|
||||
deleteInspectVar: (nodeId: string, varId: string) => Promise<void>
|
||||
deleteNodeInspectorVars: (nodeId: string) => Promise<void>
|
||||
deleteAllInspectorVars: () => Promise<void>
|
||||
isInspectVarEdited: (nodeId: string, name: string) => boolean
|
||||
resetToLastRunVar: (nodeId: string, varId: string) => Promise<void>
|
||||
invalidateSysVarValues: () => void
|
||||
resetConversationVar: (varId: string) => Promise<void>
|
||||
invalidateConversationVarValues: () => void
|
||||
configsMap?: {
|
||||
flowId: string
|
||||
flowType: FlowType
|
||||
fileSettings: FileUpload
|
||||
}
|
||||
}
|
||||
|
||||
export type Shape = {
|
||||
refreshAll: (props: Partial<CommonHooksFnMap>) => void
|
||||
} & CommonHooksFnMap
|
||||
|
||||
export const createHooksStore = ({
|
||||
doSyncWorkflowDraft = async () => noop(),
|
||||
syncWorkflowDraftWhenPageClose = noop,
|
||||
handleRefreshWorkflowDraft = noop,
|
||||
handleBackupDraft = noop,
|
||||
handleLoadBackupDraft = noop,
|
||||
handleRestoreFromPublishedWorkflow = noop,
|
||||
handleRun = noop,
|
||||
handleStopRun = noop,
|
||||
handleStartWorkflowRun = noop,
|
||||
handleWorkflowStartRunInWorkflow = noop,
|
||||
handleWorkflowStartRunInChatflow = noop,
|
||||
handleWorkflowTriggerScheduleRunInWorkflow = noop,
|
||||
handleWorkflowTriggerWebhookRunInWorkflow = noop,
|
||||
handleWorkflowTriggerPluginRunInWorkflow = noop,
|
||||
handleWorkflowRunAllTriggersInWorkflow = noop,
|
||||
availableNodesMetaData = {
|
||||
nodes: [],
|
||||
},
|
||||
getWorkflowRunAndTraceUrl = () => ({
|
||||
runUrl: '',
|
||||
traceUrl: '',
|
||||
}),
|
||||
exportCheck = async () => noop(),
|
||||
handleExportDSL = async () => noop(),
|
||||
fetchInspectVars = async () => noop(),
|
||||
hasNodeInspectVars = () => false,
|
||||
hasSetInspectVar = () => false,
|
||||
fetchInspectVarValue = async () => noop(),
|
||||
editInspectVarValue = async () => noop(),
|
||||
renameInspectVarName = async () => noop(),
|
||||
appendNodeInspectVars = () => noop(),
|
||||
deleteInspectVar = async () => noop(),
|
||||
deleteNodeInspectorVars = async () => noop(),
|
||||
deleteAllInspectorVars = async () => noop(),
|
||||
isInspectVarEdited = () => false,
|
||||
resetToLastRunVar = async () => noop(),
|
||||
invalidateSysVarValues = noop,
|
||||
resetConversationVar = async () => noop(),
|
||||
invalidateConversationVarValues = noop,
|
||||
}: Partial<Shape>) => {
|
||||
return createStore<Shape>(set => ({
|
||||
refreshAll: props => set(state => ({ ...state, ...props })),
|
||||
doSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
handleRefreshWorkflowDraft,
|
||||
handleBackupDraft,
|
||||
handleLoadBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow,
|
||||
handleRun,
|
||||
handleStopRun,
|
||||
handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
handleWorkflowStartRunInChatflow,
|
||||
handleWorkflowTriggerScheduleRunInWorkflow,
|
||||
handleWorkflowTriggerWebhookRunInWorkflow,
|
||||
handleWorkflowTriggerPluginRunInWorkflow,
|
||||
handleWorkflowRunAllTriggersInWorkflow,
|
||||
availableNodesMetaData,
|
||||
getWorkflowRunAndTraceUrl,
|
||||
exportCheck,
|
||||
handleExportDSL,
|
||||
fetchInspectVars,
|
||||
hasNodeInspectVars,
|
||||
hasSetInspectVar,
|
||||
fetchInspectVarValue,
|
||||
editInspectVarValue,
|
||||
renameInspectVarName,
|
||||
appendNodeInspectVars,
|
||||
deleteInspectVar,
|
||||
deleteNodeInspectorVars,
|
||||
deleteAllInspectorVars,
|
||||
isInspectVarEdited,
|
||||
resetToLastRunVar,
|
||||
invalidateSysVarValues,
|
||||
resetConversationVar,
|
||||
invalidateConversationVarValues,
|
||||
}))
|
||||
}
|
||||
|
||||
export function useHooksStore<T>(selector: (state: Shape) => T): T {
|
||||
const store = useContext(HooksStoreContext)
|
||||
if (!store)
|
||||
throw new Error('Missing HooksStoreContext.Provider in the tree')
|
||||
|
||||
return useZustandStore(store, selector)
|
||||
}
|
||||
|
||||
export const useHooksStoreApi = () => {
|
||||
return useContext(HooksStoreContext)!
|
||||
}
|
||||
26
dify/web/app/components/workflow/hooks/index.ts
Normal file
26
dify/web/app/components/workflow/hooks/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export * from './use-edges-interactions'
|
||||
export * from './use-node-data-update'
|
||||
export * from './use-nodes-interactions'
|
||||
export * from './use-nodes-sync-draft'
|
||||
export * from './use-workflow'
|
||||
export * from './use-workflow-run'
|
||||
export * from './use-checklist'
|
||||
export * from './use-selection-interactions'
|
||||
export * from './use-panel-interactions'
|
||||
export * from './use-workflow-start-run'
|
||||
export * from './use-nodes-layout'
|
||||
export * from './use-workflow-history'
|
||||
export * from './use-workflow-variables'
|
||||
export * from './use-shortcuts'
|
||||
export * from './use-workflow-interactions'
|
||||
export * from './use-workflow-mode'
|
||||
export * from './use-nodes-meta-data'
|
||||
export * from './use-available-blocks'
|
||||
export * from './use-workflow-refresh-draft'
|
||||
export * from './use-tool-icon'
|
||||
export * from './use-DSL'
|
||||
export * from './use-inspect-vars-crud'
|
||||
export * from './use-set-workflow-vars-with-value'
|
||||
export * from './use-workflow-search'
|
||||
export * from './use-auto-generate-webhook-url'
|
||||
export * from './use-serial-async-callback'
|
||||
11
dify/web/app/components/workflow/hooks/use-DSL.ts
Normal file
11
dify/web/app/components/workflow/hooks/use-DSL.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
|
||||
export const useDSL = () => {
|
||||
const exportCheck = useHooksStore(s => s.exportCheck)
|
||||
const handleExportDSL = useHooksStore(s => s.handleExportDSL)
|
||||
|
||||
return {
|
||||
exportCheck,
|
||||
handleExportDSL,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useCallback } from 'react'
|
||||
import { produce } from 'immer'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { fetchWebhookUrl } from '@/service/apps'
|
||||
|
||||
export const useAutoGenerateWebhookUrl = () => {
|
||||
const reactFlowStore = useStoreApi()
|
||||
|
||||
return useCallback(async (nodeId: string) => {
|
||||
const appId = useAppStore.getState().appDetail?.id
|
||||
if (!appId)
|
||||
return
|
||||
|
||||
const { getNodes } = reactFlowStore.getState()
|
||||
const node = getNodes().find(n => n.id === nodeId)
|
||||
if (!node || node.data.type !== BlockEnum.TriggerWebhook)
|
||||
return
|
||||
|
||||
if (node.data.webhook_url && node.data.webhook_url.length > 0)
|
||||
return
|
||||
|
||||
try {
|
||||
const response = await fetchWebhookUrl({ appId, nodeId })
|
||||
const { getNodes: getLatestNodes, setNodes } = reactFlowStore.getState()
|
||||
let hasUpdated = false
|
||||
const updatedNodes = produce(getLatestNodes(), (draft) => {
|
||||
const targetNode = draft.find(n => n.id === nodeId)
|
||||
if (!targetNode || targetNode.data.type !== BlockEnum.TriggerWebhook)
|
||||
return
|
||||
|
||||
targetNode.data = {
|
||||
...targetNode.data,
|
||||
webhook_url: response.webhook_url,
|
||||
webhook_debug_url: response.webhook_debug_url,
|
||||
}
|
||||
hasUpdated = true
|
||||
})
|
||||
|
||||
if (hasUpdated)
|
||||
setNodes(updatedNodes)
|
||||
}
|
||||
catch (error: unknown) {
|
||||
console.error('Failed to auto-generate webhook URL:', error)
|
||||
}
|
||||
}, [reactFlowStore])
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { BlockEnum } from '../types'
|
||||
import { useNodesMetaData } from './use-nodes-meta-data'
|
||||
|
||||
const availableBlocksFilter = (nodeType: BlockEnum, inContainer?: boolean) => {
|
||||
if (inContainer && (nodeType === BlockEnum.Iteration || nodeType === BlockEnum.Loop || nodeType === BlockEnum.End || nodeType === BlockEnum.DataSource || nodeType === BlockEnum.KnowledgeBase))
|
||||
return false
|
||||
|
||||
if (!inContainer && nodeType === BlockEnum.LoopEnd)
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const useAvailableBlocks = (nodeType?: BlockEnum, inContainer?: boolean) => {
|
||||
const {
|
||||
nodes: availableNodes,
|
||||
} = useNodesMetaData()
|
||||
const availableNodesType = useMemo(() => availableNodes.map(node => node.metaData.type), [availableNodes])
|
||||
const availablePrevBlocks = useMemo(() => {
|
||||
if (!nodeType || nodeType === BlockEnum.Start || nodeType === BlockEnum.DataSource
|
||||
|| nodeType === BlockEnum.TriggerPlugin || nodeType === BlockEnum.TriggerWebhook
|
||||
|| nodeType === BlockEnum.TriggerSchedule)
|
||||
return []
|
||||
|
||||
return availableNodesType
|
||||
}, [availableNodesType, nodeType])
|
||||
const availableNextBlocks = useMemo(() => {
|
||||
if (!nodeType || nodeType === BlockEnum.End || nodeType === BlockEnum.LoopEnd || nodeType === BlockEnum.KnowledgeBase)
|
||||
return []
|
||||
|
||||
return availableNodesType
|
||||
}, [availableNodesType, nodeType])
|
||||
|
||||
const getAvailableBlocks = useCallback((nodeType?: BlockEnum, inContainer?: boolean) => {
|
||||
let availablePrevBlocks = availableNodesType
|
||||
if (!nodeType || nodeType === BlockEnum.Start || nodeType === BlockEnum.DataSource)
|
||||
availablePrevBlocks = []
|
||||
|
||||
let availableNextBlocks = availableNodesType
|
||||
if (!nodeType || nodeType === BlockEnum.End || nodeType === BlockEnum.LoopEnd || nodeType === BlockEnum.KnowledgeBase)
|
||||
availableNextBlocks = []
|
||||
|
||||
return {
|
||||
availablePrevBlocks: availablePrevBlocks.filter(nType => availableBlocksFilter(nType, inContainer)),
|
||||
availableNextBlocks: availableNextBlocks.filter(nType => availableBlocksFilter(nType, inContainer)),
|
||||
}
|
||||
}, [availableNodesType])
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
getAvailableBlocks,
|
||||
availablePrevBlocks: availablePrevBlocks.filter(nType => availableBlocksFilter(nType, inContainer)),
|
||||
availableNextBlocks: availableNextBlocks.filter(nType => availableBlocksFilter(nType, inContainer)),
|
||||
}
|
||||
}, [getAvailableBlocks, availablePrevBlocks, availableNextBlocks, inContainer])
|
||||
}
|
||||
429
dify/web/app/components/workflow/hooks/use-checklist.ts
Normal file
429
dify/web/app/components/workflow/hooks/use-checklist.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEdges, useStoreApi } from 'reactflow'
|
||||
import type {
|
||||
CommonEdgeType,
|
||||
CommonNodeType,
|
||||
Edge,
|
||||
Node,
|
||||
ValueSelector,
|
||||
} from '../types'
|
||||
import { BlockEnum } from '../types'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
} from '../store'
|
||||
import {
|
||||
getDataSourceCheckParams,
|
||||
getToolCheckParams,
|
||||
getValidTreeNodes,
|
||||
} from '../utils'
|
||||
import { getTriggerCheckParams } from '../utils/trigger'
|
||||
import {
|
||||
CUSTOM_NODE,
|
||||
} from '../constants'
|
||||
import {
|
||||
useGetToolIcon,
|
||||
useNodesMetaData,
|
||||
} from '../hooks'
|
||||
import type { ToolNodeType } from '../nodes/tool/types'
|
||||
import type { DataSourceNodeType } from '../nodes/data-source/types'
|
||||
import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import type { AgentNodeType } from '../nodes/agent/types'
|
||||
import { useStrategyProviders } from '@/service/use-strategy'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
import { useDatasetsDetailStore } from '../datasets-detail-store/store'
|
||||
import type { KnowledgeRetrievalNodeType } from '../nodes/knowledge-retrieval/types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fetchDatasets } from '@/service/datasets'
|
||||
import { MAX_TREE_DEPTH } from '@/config'
|
||||
import useNodesAvailableVarList, { useGetNodesAvailableVarList } from './use-nodes-available-var-list'
|
||||
import { getNodeUsedVars, isSpecialVar } from '../nodes/_base/components/variable/utils'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { KnowledgeBaseNodeType } from '../nodes/knowledge-base/types'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
|
||||
export type ChecklistItem = {
|
||||
id: string
|
||||
type: BlockEnum | string
|
||||
title: string
|
||||
toolIcon?: string | Emoji
|
||||
unConnected?: boolean
|
||||
errorMessage?: string
|
||||
canNavigate: boolean
|
||||
}
|
||||
|
||||
const START_NODE_TYPES: BlockEnum[] = [
|
||||
BlockEnum.Start,
|
||||
BlockEnum.TriggerSchedule,
|
||||
BlockEnum.TriggerWebhook,
|
||||
BlockEnum.TriggerPlugin,
|
||||
]
|
||||
|
||||
export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const { nodesMap: nodesExtraData } = useNodesMetaData()
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const dataSourceList = useStore(s => s.dataSourceList)
|
||||
const { data: strategyProviders } = useStrategyProviders()
|
||||
const { data: triggerPlugins } = useAllTriggerPlugins()
|
||||
const datasetsDetail = useDatasetsDetailStore(s => s.datasetsDetail)
|
||||
const getToolIcon = useGetToolIcon()
|
||||
const appMode = useAppStore.getState().appDetail?.mode
|
||||
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
|
||||
|
||||
const map = useNodesAvailableVarList(nodes)
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
|
||||
|
||||
const getCheckData = useCallback((data: CommonNodeType<{}>) => {
|
||||
let checkData = data
|
||||
if (data.type === BlockEnum.KnowledgeRetrieval) {
|
||||
const datasetIds = (data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids
|
||||
const _datasets = datasetIds.reduce<DataSet[]>((acc, id) => {
|
||||
if (datasetsDetail[id])
|
||||
acc.push(datasetsDetail[id])
|
||||
return acc
|
||||
}, [])
|
||||
checkData = {
|
||||
...data,
|
||||
_datasets,
|
||||
} as CommonNodeType<KnowledgeRetrievalNodeType>
|
||||
}
|
||||
else if (data.type === BlockEnum.KnowledgeBase) {
|
||||
checkData = {
|
||||
...data,
|
||||
_embeddingModelList: embeddingModelList,
|
||||
_rerankModelList: rerankModelList,
|
||||
} as CommonNodeType<KnowledgeBaseNodeType>
|
||||
}
|
||||
return checkData
|
||||
}, [datasetsDetail, embeddingModelList, rerankModelList])
|
||||
|
||||
const needWarningNodes = useMemo<ChecklistItem[]>(() => {
|
||||
const list: ChecklistItem[] = []
|
||||
const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE)
|
||||
const { validNodes } = getValidTreeNodes(filteredNodes, edges)
|
||||
|
||||
for (let i = 0; i < filteredNodes.length; i++) {
|
||||
const node = filteredNodes[i]
|
||||
let moreDataForCheckValid
|
||||
let usedVars: ValueSelector[] = []
|
||||
|
||||
if (node.data.type === BlockEnum.Tool)
|
||||
moreDataForCheckValid = getToolCheckParams(node.data as ToolNodeType, buildInTools || [], customTools || [], workflowTools || [], language)
|
||||
|
||||
if (node.data.type === BlockEnum.DataSource)
|
||||
moreDataForCheckValid = getDataSourceCheckParams(node.data as DataSourceNodeType, dataSourceList || [], language)
|
||||
|
||||
if (node.data.type === BlockEnum.TriggerPlugin)
|
||||
moreDataForCheckValid = getTriggerCheckParams(node.data as PluginTriggerNodeType, triggerPlugins, language)
|
||||
|
||||
const toolIcon = getToolIcon(node.data)
|
||||
if (node.data.type === BlockEnum.Agent) {
|
||||
const data = node.data as AgentNodeType
|
||||
const isReadyForCheckValid = !!strategyProviders
|
||||
const provider = strategyProviders?.find(provider => provider.declaration.identity.name === data.agent_strategy_provider_name)
|
||||
const strategy = provider?.declaration.strategies?.find(s => s.identity.name === data.agent_strategy_name)
|
||||
moreDataForCheckValid = {
|
||||
provider,
|
||||
strategy,
|
||||
language,
|
||||
isReadyForCheckValid,
|
||||
}
|
||||
}
|
||||
else {
|
||||
usedVars = getNodeUsedVars(node).filter(v => v.length > 0)
|
||||
}
|
||||
|
||||
if (node.type === CUSTOM_NODE) {
|
||||
const checkData = getCheckData(node.data)
|
||||
const validator = nodesExtraData?.[node.data.type as BlockEnum]?.checkValid
|
||||
let errorMessage = validator ? validator(checkData, t, moreDataForCheckValid).errorMessage : undefined
|
||||
|
||||
if (!errorMessage) {
|
||||
const availableVars = map[node.id].availableVars
|
||||
|
||||
for (const variable of usedVars) {
|
||||
const isSpecialVars = isSpecialVar(variable[0])
|
||||
if (!isSpecialVars) {
|
||||
const usedNode = availableVars.find(v => v.nodeId === variable?.[0])
|
||||
if (usedNode) {
|
||||
const usedVar = usedNode.vars.find(v => v.variable === variable?.[1])
|
||||
if (!usedVar)
|
||||
errorMessage = t('workflow.errorMsg.invalidVariable')
|
||||
}
|
||||
else {
|
||||
errorMessage = t('workflow.errorMsg.invalidVariable')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start nodes and Trigger nodes should not show unConnected error if they have validation errors
|
||||
// or if they are valid start nodes (even without incoming connections)
|
||||
const isStartNodeMeta = nodesExtraData?.[node.data.type as BlockEnum]?.metaData.isStart ?? false
|
||||
const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta : true
|
||||
|
||||
const isUnconnected = !validNodes.find(n => n.id === node.id)
|
||||
const shouldShowError = errorMessage || (isUnconnected && !canSkipConnectionCheck)
|
||||
|
||||
if (shouldShowError) {
|
||||
list.push({
|
||||
id: node.id,
|
||||
type: node.data.type,
|
||||
title: node.data.title,
|
||||
toolIcon,
|
||||
unConnected: isUnconnected && !canSkipConnectionCheck,
|
||||
errorMessage,
|
||||
canNavigate: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for start nodes (including triggers)
|
||||
if (shouldCheckStartNode) {
|
||||
const startNodesFiltered = nodes.filter(node => START_NODE_TYPES.includes(node.data.type as BlockEnum))
|
||||
if (startNodesFiltered.length === 0) {
|
||||
list.push({
|
||||
id: 'start-node-required',
|
||||
type: BlockEnum.Start,
|
||||
title: t('workflow.panel.startNode'),
|
||||
errorMessage: t('workflow.common.needStartNode'),
|
||||
canNavigate: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const isRequiredNodesType = Object.keys(nodesExtraData!).filter((key: any) => (nodesExtraData as any)[key].metaData.isRequired)
|
||||
|
||||
isRequiredNodesType.forEach((type: string) => {
|
||||
if (!filteredNodes.find(node => node.data.type === type)) {
|
||||
list.push({
|
||||
id: `${type}-need-added`,
|
||||
type,
|
||||
title: t(`workflow.blocks.${type}`),
|
||||
errorMessage: t('workflow.common.needAdd', { node: t(`workflow.blocks.${type}`) }),
|
||||
canNavigate: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return list
|
||||
}, [nodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, getCheckData, t, map, shouldCheckStartNode])
|
||||
|
||||
return needWarningNodes
|
||||
}
|
||||
|
||||
export const useChecklistBeforePublish = () => {
|
||||
const { t } = useTranslation()
|
||||
const language = useGetLanguage()
|
||||
const { notify } = useToastContext()
|
||||
const store = useStoreApi()
|
||||
const { nodesMap: nodesExtraData } = useNodesMetaData()
|
||||
const { data: strategyProviders } = useStrategyProviders()
|
||||
const updateDatasetsDetail = useDatasetsDetailStore(s => s.updateDatasetsDetail)
|
||||
const updateTime = useRef(0)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { getNodesAvailableVarList } = useGetNodesAvailableVarList()
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const appMode = useAppStore.getState().appDetail?.mode
|
||||
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
|
||||
|
||||
const getCheckData = useCallback((data: CommonNodeType<{}>, datasets: DataSet[]) => {
|
||||
let checkData = data
|
||||
if (data.type === BlockEnum.KnowledgeRetrieval) {
|
||||
const datasetIds = (data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids
|
||||
const datasetsDetail = datasets.reduce<Record<string, DataSet>>((acc, dataset) => {
|
||||
acc[dataset.id] = dataset
|
||||
return acc
|
||||
}, {})
|
||||
const _datasets = datasetIds.reduce<DataSet[]>((acc, id) => {
|
||||
if (datasetsDetail[id])
|
||||
acc.push(datasetsDetail[id])
|
||||
return acc
|
||||
}, [])
|
||||
checkData = {
|
||||
...data,
|
||||
_datasets,
|
||||
} as CommonNodeType<KnowledgeRetrievalNodeType>
|
||||
}
|
||||
else if (data.type === BlockEnum.KnowledgeBase) {
|
||||
checkData = {
|
||||
...data,
|
||||
_embeddingModelList: embeddingModelList,
|
||||
_rerankModelList: rerankModelList,
|
||||
} as CommonNodeType<KnowledgeBaseNodeType>
|
||||
}
|
||||
return checkData
|
||||
}, [embeddingModelList, rerankModelList])
|
||||
|
||||
const handleCheckBeforePublish = useCallback(async () => {
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
} = store.getState()
|
||||
const {
|
||||
dataSourceList,
|
||||
} = workflowStore.getState()
|
||||
const nodes = getNodes()
|
||||
const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE)
|
||||
const { validNodes, maxDepth } = getValidTreeNodes(filteredNodes, edges)
|
||||
|
||||
if (maxDepth > MAX_TREE_DEPTH) {
|
||||
notify({ type: 'error', message: t('workflow.common.maxTreeDepth', { depth: MAX_TREE_DEPTH }) })
|
||||
return false
|
||||
}
|
||||
// Before publish, we need to fetch datasets detail, in case of the settings of datasets have been changed
|
||||
const knowledgeRetrievalNodes = filteredNodes.filter(node => node.data.type === BlockEnum.KnowledgeRetrieval)
|
||||
const allDatasetIds = knowledgeRetrievalNodes.reduce<string[]>((acc, node) => {
|
||||
return Array.from(new Set([...acc, ...(node.data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids]))
|
||||
}, [])
|
||||
let datasets: DataSet[] = []
|
||||
if (allDatasetIds.length > 0) {
|
||||
updateTime.current = updateTime.current + 1
|
||||
const currUpdateTime = updateTime.current
|
||||
const { data: datasetsDetail } = await fetchDatasets({ url: '/datasets', params: { page: 1, ids: allDatasetIds } })
|
||||
if (datasetsDetail && datasetsDetail.length > 0) {
|
||||
// avoid old data to overwrite the new data
|
||||
if (currUpdateTime < updateTime.current)
|
||||
return false
|
||||
datasets = datasetsDetail
|
||||
updateDatasetsDetail(datasetsDetail)
|
||||
}
|
||||
}
|
||||
const map = getNodesAvailableVarList(nodes)
|
||||
for (let i = 0; i < filteredNodes.length; i++) {
|
||||
const node = filteredNodes[i]
|
||||
let moreDataForCheckValid
|
||||
let usedVars: ValueSelector[] = []
|
||||
if (node.data.type === BlockEnum.Tool)
|
||||
moreDataForCheckValid = getToolCheckParams(node.data as ToolNodeType, buildInTools || [], customTools || [], workflowTools || [], language)
|
||||
|
||||
if (node.data.type === BlockEnum.DataSource)
|
||||
moreDataForCheckValid = getDataSourceCheckParams(node.data as DataSourceNodeType, dataSourceList || [], language)
|
||||
|
||||
if (node.data.type === BlockEnum.Agent) {
|
||||
const data = node.data as AgentNodeType
|
||||
const isReadyForCheckValid = !!strategyProviders
|
||||
const provider = strategyProviders?.find(provider => provider.declaration.identity.name === data.agent_strategy_provider_name)
|
||||
const strategy = provider?.declaration.strategies?.find(s => s.identity.name === data.agent_strategy_name)
|
||||
moreDataForCheckValid = {
|
||||
provider,
|
||||
strategy,
|
||||
language,
|
||||
isReadyForCheckValid,
|
||||
}
|
||||
}
|
||||
else {
|
||||
usedVars = getNodeUsedVars(node).filter(v => v.length > 0)
|
||||
}
|
||||
const checkData = getCheckData(node.data, datasets)
|
||||
const { errorMessage } = nodesExtraData![node.data.type as BlockEnum].checkValid(checkData, t, moreDataForCheckValid)
|
||||
|
||||
if (errorMessage) {
|
||||
notify({ type: 'error', message: `[${node.data.title}] ${errorMessage}` })
|
||||
return false
|
||||
}
|
||||
|
||||
const availableVars = map[node.id].availableVars
|
||||
|
||||
for (const variable of usedVars) {
|
||||
const isSpecialVars = isSpecialVar(variable[0])
|
||||
if (!isSpecialVars) {
|
||||
const usedNode = availableVars.find(v => v.nodeId === variable?.[0])
|
||||
if (usedNode) {
|
||||
const usedVar = usedNode.vars.find(v => v.variable === variable?.[1])
|
||||
if (!usedVar) {
|
||||
notify({ type: 'error', message: `[${node.data.title}] ${t('workflow.errorMsg.invalidVariable')}` })
|
||||
return false
|
||||
}
|
||||
}
|
||||
else {
|
||||
notify({ type: 'error', message: `[${node.data.title}] ${t('workflow.errorMsg.invalidVariable')}` })
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isStartNodeMeta = nodesExtraData?.[node.data.type as BlockEnum]?.metaData.isStart ?? false
|
||||
const canSkipConnectionCheck = shouldCheckStartNode ? isStartNodeMeta : true
|
||||
const isUnconnected = !validNodes.find(n => n.id === node.id)
|
||||
|
||||
if (isUnconnected && !canSkipConnectionCheck) {
|
||||
notify({ type: 'error', message: `[${node.data.title}] ${t('workflow.common.needConnectTip')}` })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldCheckStartNode) {
|
||||
const startNodesFiltered = nodes.filter(node => START_NODE_TYPES.includes(node.data.type as BlockEnum))
|
||||
if (startNodesFiltered.length === 0) {
|
||||
notify({ type: 'error', message: t('workflow.common.needStartNode') })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const isRequiredNodesType = Object.keys(nodesExtraData!).filter((key: any) => (nodesExtraData as any)[key].metaData.isRequired)
|
||||
|
||||
for (let i = 0; i < isRequiredNodesType.length; i++) {
|
||||
const type = isRequiredNodesType[i]
|
||||
|
||||
if (!filteredNodes.find(node => node.data.type === type)) {
|
||||
notify({ type: 'error', message: t('workflow.common.needAdd', { node: t(`workflow.blocks.${type}`) }) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, workflowStore, buildInTools, customTools, workflowTools, shouldCheckStartNode])
|
||||
|
||||
return {
|
||||
handleCheckBeforePublish,
|
||||
}
|
||||
}
|
||||
|
||||
export const useWorkflowRunValidation = () => {
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes()
|
||||
const edges = useEdges<CommonEdgeType>()
|
||||
const needWarningNodes = useChecklist(nodes, edges)
|
||||
const { notify } = useToastContext()
|
||||
|
||||
const validateBeforeRun = useCallback(() => {
|
||||
if (needWarningNodes.length > 0) {
|
||||
notify({ type: 'error', message: t('workflow.panel.checklistTip') })
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [needWarningNodes, notify, t])
|
||||
|
||||
return {
|
||||
validateBeforeRun,
|
||||
hasValidationErrors: needWarningNodes.length > 0,
|
||||
warningNodes: needWarningNodes,
|
||||
}
|
||||
}
|
||||
88
dify/web/app/components/workflow/hooks/use-config-vision.ts
Normal file
88
dify/web/app/components/workflow/hooks/use-config-vision.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useIsChatMode } from './use-workflow'
|
||||
import type { ModelConfig, VisionSetting } from '@/app/components/workflow/types'
|
||||
import { useTextGenerationCurrentProviderAndModelAndModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import {
|
||||
ModelFeatureEnum,
|
||||
} from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { Resolution } from '@/types/app'
|
||||
|
||||
type Payload = {
|
||||
enabled: boolean
|
||||
configs?: VisionSetting
|
||||
}
|
||||
|
||||
type Params = {
|
||||
payload: Payload
|
||||
onChange: (payload: Payload) => void
|
||||
}
|
||||
const useConfigVision = (model: ModelConfig, {
|
||||
payload = {
|
||||
enabled: false,
|
||||
},
|
||||
onChange,
|
||||
}: Params) => {
|
||||
const {
|
||||
currentModel: currModel,
|
||||
} = useTextGenerationCurrentProviderAndModelAndModelList(
|
||||
{
|
||||
provider: model.provider,
|
||||
model: model.name,
|
||||
},
|
||||
)
|
||||
|
||||
const isChatMode = useIsChatMode()
|
||||
|
||||
const getIsVisionModel = useCallback(() => {
|
||||
return !!currModel?.features?.includes(ModelFeatureEnum.vision)
|
||||
}, [currModel])
|
||||
|
||||
const isVisionModel = getIsVisionModel()
|
||||
|
||||
const handleVisionResolutionEnabledChange = useCallback((enabled: boolean) => {
|
||||
const newPayload = produce(payload, (draft) => {
|
||||
draft.enabled = enabled
|
||||
if (enabled && isChatMode) {
|
||||
draft.configs = {
|
||||
detail: Resolution.high,
|
||||
variable_selector: ['sys', 'files'],
|
||||
}
|
||||
}
|
||||
})
|
||||
onChange(newPayload)
|
||||
}, [isChatMode, onChange, payload])
|
||||
|
||||
const handleVisionResolutionChange = useCallback((config: VisionSetting) => {
|
||||
const newPayload = produce(payload, (draft) => {
|
||||
draft.configs = config
|
||||
})
|
||||
onChange(newPayload)
|
||||
}, [onChange, payload])
|
||||
|
||||
const handleModelChanged = useCallback(() => {
|
||||
const isVisionModel = getIsVisionModel()
|
||||
if (!isVisionModel) {
|
||||
handleVisionResolutionEnabledChange(false)
|
||||
return
|
||||
}
|
||||
if (payload.enabled) {
|
||||
onChange({
|
||||
enabled: true,
|
||||
configs: {
|
||||
detail: Resolution.high,
|
||||
variable_selector: [],
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [getIsVisionModel, handleVisionResolutionEnabledChange, onChange, payload.enabled])
|
||||
|
||||
return {
|
||||
isVisionModel,
|
||||
handleVisionResolutionEnabledChange,
|
||||
handleVisionResolutionChange,
|
||||
handleModelChanged,
|
||||
}
|
||||
}
|
||||
|
||||
export default useConfigVision
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useMemo } from 'react'
|
||||
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { BlockEnum, type CommonNodeType } from '../types'
|
||||
import { getWorkflowEntryNode } from '../utils/workflow-entry'
|
||||
import { type TestRunOptions, type TriggerOption, TriggerType } from '../header/test-run-menu'
|
||||
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
|
||||
import BlockIcon from '../block-icon'
|
||||
import { useStore } from '../store'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
|
||||
export const useDynamicTestRunOptions = (): TestRunOptions => {
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes()
|
||||
const buildInTools = useStore(s => s.buildInTools)
|
||||
const customTools = useStore(s => s.customTools)
|
||||
const workflowTools = useStore(s => s.workflowTools)
|
||||
const mcpTools = useStore(s => s.mcpTools)
|
||||
const { data: triggerPlugins } = useAllTriggerPlugins()
|
||||
|
||||
return useMemo(() => {
|
||||
const allTriggers: TriggerOption[] = []
|
||||
let userInput: TriggerOption | undefined
|
||||
|
||||
for (const node of nodes) {
|
||||
const nodeData = node.data as CommonNodeType
|
||||
|
||||
if (!nodeData?.type) continue
|
||||
|
||||
if (nodeData.type === BlockEnum.Start) {
|
||||
userInput = {
|
||||
id: node.id,
|
||||
type: TriggerType.UserInput,
|
||||
name: nodeData.title || t('workflow.blocks.start'),
|
||||
icon: (
|
||||
<BlockIcon
|
||||
type={BlockEnum.Start}
|
||||
size='md'
|
||||
/>
|
||||
),
|
||||
nodeId: node.id,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
else if (nodeData.type === BlockEnum.TriggerSchedule) {
|
||||
allTriggers.push({
|
||||
id: node.id,
|
||||
type: TriggerType.Schedule,
|
||||
name: nodeData.title || t('workflow.blocks.trigger-schedule'),
|
||||
icon: (
|
||||
<BlockIcon
|
||||
type={BlockEnum.TriggerSchedule}
|
||||
size='md'
|
||||
/>
|
||||
),
|
||||
nodeId: node.id,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
else if (nodeData.type === BlockEnum.TriggerWebhook) {
|
||||
allTriggers.push({
|
||||
id: node.id,
|
||||
type: TriggerType.Webhook,
|
||||
name: nodeData.title || t('workflow.blocks.trigger-webhook'),
|
||||
icon: (
|
||||
<BlockIcon
|
||||
type={BlockEnum.TriggerWebhook}
|
||||
size='md'
|
||||
/>
|
||||
),
|
||||
nodeId: node.id,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
else if (nodeData.type === BlockEnum.TriggerPlugin) {
|
||||
let triggerIcon: string | any
|
||||
|
||||
if (nodeData.provider_id) {
|
||||
const targetTriggers = triggerPlugins || []
|
||||
triggerIcon = targetTriggers.find(toolWithProvider => toolWithProvider.name === nodeData.provider_id)?.icon
|
||||
}
|
||||
|
||||
const icon = (
|
||||
<BlockIcon
|
||||
type={BlockEnum.TriggerPlugin}
|
||||
size='md'
|
||||
toolIcon={triggerIcon}
|
||||
/>
|
||||
)
|
||||
|
||||
allTriggers.push({
|
||||
id: node.id,
|
||||
type: TriggerType.Plugin,
|
||||
name: nodeData.title || (nodeData as any).plugin_name || t('workflow.blocks.trigger-plugin'),
|
||||
icon,
|
||||
nodeId: node.id,
|
||||
enabled: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!userInput) {
|
||||
const startNode = getWorkflowEntryNode(nodes as any[])
|
||||
if (startNode && startNode.data?.type === BlockEnum.Start) {
|
||||
userInput = {
|
||||
id: startNode.id,
|
||||
type: TriggerType.UserInput,
|
||||
name: (startNode.data as CommonNodeType)?.title || t('workflow.blocks.start'),
|
||||
icon: (
|
||||
<BlockIcon
|
||||
type={BlockEnum.Start}
|
||||
size='md'
|
||||
/>
|
||||
),
|
||||
nodeId: startNode.id,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggerNodeIds = allTriggers
|
||||
.map(trigger => trigger.nodeId)
|
||||
.filter((nodeId): nodeId is string => Boolean(nodeId))
|
||||
|
||||
const runAll: TriggerOption | undefined = triggerNodeIds.length > 1 ? {
|
||||
id: 'run-all',
|
||||
type: TriggerType.All,
|
||||
name: t('workflow.common.runAllTriggers'),
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-lg border-[0.5px] border-white/2 bg-util-colors-purple-purple-500 text-white shadow-md">
|
||||
<TriggerAll className="h-4.5 w-4.5" />
|
||||
</div>
|
||||
),
|
||||
relatedNodeIds: triggerNodeIds,
|
||||
enabled: true,
|
||||
} : undefined
|
||||
|
||||
return {
|
||||
userInput,
|
||||
triggers: allTriggers,
|
||||
runAll,
|
||||
}
|
||||
}, [nodes, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins, t])
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react'
|
||||
import { produce } from 'immer'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
|
||||
export const useEdgesInteractionsWithoutSync = () => {
|
||||
const store = useStoreApi()
|
||||
|
||||
const handleEdgeCancelRunningStatus = useCallback(() => {
|
||||
const {
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
edge.data._sourceRunningStatus = undefined
|
||||
edge.data._targetRunningStatus = undefined
|
||||
edge.data._waitingRun = false
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store])
|
||||
|
||||
return {
|
||||
handleEdgeCancelRunningStatus,
|
||||
}
|
||||
}
|
||||
161
dify/web/app/components/workflow/hooks/use-edges-interactions.ts
Normal file
161
dify/web/app/components/workflow/hooks/use-edges-interactions.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useCallback } from 'react'
|
||||
import { produce } from 'immer'
|
||||
import type {
|
||||
EdgeMouseHandler,
|
||||
OnEdgesChange,
|
||||
} from 'reactflow'
|
||||
import {
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import type {
|
||||
Node,
|
||||
} from '../types'
|
||||
import { getNodesConnectedSourceOrTargetHandleIdsMap } from '../utils'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
import { useNodesReadOnly } from './use-workflow'
|
||||
import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history'
|
||||
|
||||
export const useEdgesInteractions = () => {
|
||||
const store = useStoreApi()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
|
||||
const handleEdgeEnter = useCallback<EdgeMouseHandler>((_, edge) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
const currentEdge = draft.find(e => e.id === edge.id)!
|
||||
|
||||
currentEdge.data._hovering = true
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store, getNodesReadOnly])
|
||||
|
||||
const handleEdgeLeave = useCallback<EdgeMouseHandler>((_, edge) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
const currentEdge = draft.find(e => e.id === edge.id)!
|
||||
|
||||
currentEdge.data._hovering = false
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store, getNodesReadOnly])
|
||||
|
||||
const handleEdgeDeleteByDeleteBranch = useCallback((nodeId: string, branchId: string) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
const edgeWillBeDeleted = edges.filter(edge => edge.source === nodeId && edge.sourceHandle === branchId)
|
||||
|
||||
if (!edgeWillBeDeleted.length)
|
||||
return
|
||||
|
||||
const nodes = getNodes()
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
edgeWillBeDeleted.map(edge => ({ type: 'remove', edge })),
|
||||
nodes,
|
||||
)
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
draft.forEach((node) => {
|
||||
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
return draft.filter(edge => !edgeWillBeDeleted.find(e => e.id === edge.id))
|
||||
})
|
||||
setEdges(newEdges)
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDeleteByDeleteBranch)
|
||||
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
const handleEdgeDelete = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
const currentEdgeIndex = edges.findIndex(edge => edge.selected)
|
||||
|
||||
if (currentEdgeIndex < 0)
|
||||
return
|
||||
const currentEdge = edges[currentEdgeIndex]
|
||||
const nodes = getNodes()
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[
|
||||
{ type: 'remove', edge: currentEdge },
|
||||
],
|
||||
nodes,
|
||||
)
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
draft.forEach((node) => {
|
||||
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.splice(currentEdgeIndex, 1)
|
||||
})
|
||||
setEdges(newEdges)
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeDelete)
|
||||
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
const handleEdgesChange = useCallback<OnEdgesChange>((changes) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const {
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
changes.forEach((change) => {
|
||||
if (change.type === 'select')
|
||||
draft.find(edge => edge.id === change.id)!.selected = change.selected
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store, getNodesReadOnly])
|
||||
|
||||
return {
|
||||
handleEdgeEnter,
|
||||
handleEdgeLeave,
|
||||
handleEdgeDeleteByDeleteBranch,
|
||||
handleEdgeDelete,
|
||||
handleEdgesChange,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { NodeWithVar, VarInInspect } from '@/types/workflow'
|
||||
import { useStore, useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { fetchAllInspectVars } from '@/service/workflow'
|
||||
import { useInvalidateConversationVarValues, useInvalidateSysVarValues } from '@/service/use-workflow'
|
||||
import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync'
|
||||
import type { FlowType } from '@/types/common'
|
||||
import useMatchSchemaType, { getMatchedSchemaType } from '../nodes/_base/components/variable/use-match-schema-type'
|
||||
import { toNodeOutputVars } from '../nodes/_base/components/variable/utils'
|
||||
import type { SchemaTypeDefinition } from '@/service/use-common'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
|
||||
type Params = {
|
||||
flowType: FlowType
|
||||
flowId: string
|
||||
}
|
||||
|
||||
export const useSetWorkflowVarsWithValue = ({
|
||||
flowType,
|
||||
flowId,
|
||||
}: Params) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const store = useStoreApi()
|
||||
const invalidateConversationVarValues = useInvalidateConversationVarValues(flowType, flowId)
|
||||
const invalidateSysVarValues = useInvalidateSysVarValues(flowType, flowId)
|
||||
const { handleCancelAllNodeSuccessStatus } = useNodesInteractionsWithoutSync()
|
||||
const { schemaTypeDefinitions } = useMatchSchemaType()
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const dataSourceList = useStore(s => s.dataSourceList)
|
||||
const allPluginInfoList = {
|
||||
buildInTools: buildInTools || [],
|
||||
customTools: customTools || [],
|
||||
workflowTools: workflowTools || [],
|
||||
mcpTools: mcpTools || [],
|
||||
dataSourceList: dataSourceList || [],
|
||||
}
|
||||
|
||||
const setInspectVarsToStore = (inspectVars: VarInInspect[], passedInAllPluginInfoList?: Record<string, ToolWithProvider[]>, passedInSchemaTypeDefinitions?: SchemaTypeDefinition[]) => {
|
||||
const { setNodesWithInspectVars } = workflowStore.getState()
|
||||
const { getNodes } = store.getState()
|
||||
|
||||
const nodeArr = getNodes()
|
||||
const allNodesOutputVars = toNodeOutputVars(nodeArr, false, () => true, [], [], [], passedInAllPluginInfoList || allPluginInfoList, passedInSchemaTypeDefinitions || schemaTypeDefinitions)
|
||||
|
||||
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 nodeVar = allNodesOutputVars.find(item => item.nodeId === nodeId)
|
||||
|
||||
const nodeWithVar = {
|
||||
nodeId,
|
||||
nodePayload: node.data,
|
||||
nodeType: node.data.type,
|
||||
title: node.data.title,
|
||||
vars: varsUnderTheNode.map((item) => {
|
||||
const schemaType = nodeVar ? nodeVar.vars.find(v => v.variable === item.name)?.schemaType : ''
|
||||
return {
|
||||
...item,
|
||||
schemaType,
|
||||
}
|
||||
}),
|
||||
isSingRunRunning: false,
|
||||
isValueFetched: false,
|
||||
}
|
||||
return nodeWithVar
|
||||
})
|
||||
setNodesWithInspectVars(res)
|
||||
}
|
||||
|
||||
const fetchInspectVars = useCallback(async (params: {
|
||||
passInVars?: boolean,
|
||||
vars?: VarInInspect[],
|
||||
passedInAllPluginInfoList?: Record<string, ToolWithProvider[]>,
|
||||
passedInSchemaTypeDefinitions?: SchemaTypeDefinition[]
|
||||
}) => {
|
||||
const { passInVars, vars, passedInAllPluginInfoList, passedInSchemaTypeDefinitions } = params
|
||||
invalidateConversationVarValues()
|
||||
invalidateSysVarValues()
|
||||
const data = passInVars ? vars! : await fetchAllInspectVars(flowType, flowId)
|
||||
setInspectVarsToStore(data, passedInAllPluginInfoList, passedInSchemaTypeDefinitions)
|
||||
handleCancelAllNodeSuccessStatus() // to make sure clear node output show the unset status
|
||||
}, [invalidateConversationVarValues, invalidateSysVarValues, flowType, flowId, setInspectVarsToStore, handleCancelAllNodeSuccessStatus, schemaTypeDefinitions, getMatchedSchemaType])
|
||||
return {
|
||||
fetchInspectVars,
|
||||
}
|
||||
}
|
||||
196
dify/web/app/components/workflow/hooks/use-helpline.ts
Normal file
196
dify/web/app/components/workflow/hooks/use-helpline.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import type { Node } from '../types'
|
||||
import { BlockEnum, isTriggerNode } from '../types'
|
||||
import { useWorkflowStore } from '../store'
|
||||
|
||||
// Entry node (Start/Trigger) wrapper offsets
|
||||
// The EntryNodeContainer adds a wrapper with status indicator above the actual node
|
||||
// These offsets ensure alignment happens on the inner node, not the wrapper
|
||||
const ENTRY_NODE_WRAPPER_OFFSET = {
|
||||
x: 0, // No horizontal padding on wrapper (px-0)
|
||||
y: 21, // Actual measured: pt-0.5 (2px) + status bar height (~19px)
|
||||
} as const
|
||||
|
||||
export const useHelpline = () => {
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
// Check if a node is an entry node (Start or Trigger)
|
||||
const isEntryNode = useCallback((node: Node): boolean => {
|
||||
return isTriggerNode(node.data.type as any) || node.data.type === BlockEnum.Start
|
||||
}, [])
|
||||
|
||||
// Get the actual alignment position of a node (accounting for wrapper offset)
|
||||
const getNodeAlignPosition = useCallback((node: Node) => {
|
||||
if (isEntryNode(node)) {
|
||||
return {
|
||||
x: node.position.x + ENTRY_NODE_WRAPPER_OFFSET.x,
|
||||
y: node.position.y + ENTRY_NODE_WRAPPER_OFFSET.y,
|
||||
}
|
||||
}
|
||||
return {
|
||||
x: node.position.x,
|
||||
y: node.position.y,
|
||||
}
|
||||
}, [isEntryNode])
|
||||
|
||||
const handleSetHelpline = useCallback((node: Node) => {
|
||||
const { getNodes } = store.getState()
|
||||
const nodes = getNodes()
|
||||
const {
|
||||
setHelpLineHorizontal,
|
||||
setHelpLineVertical,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (node.data.isInIteration) {
|
||||
return {
|
||||
showHorizontalHelpLineNodes: [],
|
||||
showVerticalHelpLineNodes: [],
|
||||
}
|
||||
}
|
||||
|
||||
if (node.data.isInLoop) {
|
||||
return {
|
||||
showHorizontalHelpLineNodes: [],
|
||||
showVerticalHelpLineNodes: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Get the actual alignment position for the dragging node
|
||||
const nodeAlignPos = getNodeAlignPosition(node)
|
||||
|
||||
const showHorizontalHelpLineNodes = nodes.filter((n) => {
|
||||
if (n.id === node.id)
|
||||
return false
|
||||
|
||||
if (n.data.isInIteration)
|
||||
return false
|
||||
|
||||
if (n.data.isInLoop)
|
||||
return false
|
||||
|
||||
// Get actual alignment position for comparison node
|
||||
const nAlignPos = getNodeAlignPosition(n)
|
||||
const nY = Math.ceil(nAlignPos.y)
|
||||
const nodeY = Math.ceil(nodeAlignPos.y)
|
||||
|
||||
if (nY - nodeY < 5 && nY - nodeY > -5)
|
||||
return true
|
||||
|
||||
return false
|
||||
}).sort((a, b) => {
|
||||
const aPos = getNodeAlignPosition(a)
|
||||
const bPos = getNodeAlignPosition(b)
|
||||
return aPos.x - bPos.x
|
||||
})
|
||||
|
||||
const showHorizontalHelpLineNodesLength = showHorizontalHelpLineNodes.length
|
||||
if (showHorizontalHelpLineNodesLength > 0) {
|
||||
const first = showHorizontalHelpLineNodes[0]
|
||||
const last = showHorizontalHelpLineNodes[showHorizontalHelpLineNodesLength - 1]
|
||||
|
||||
// Use actual alignment positions for help line rendering
|
||||
const firstPos = getNodeAlignPosition(first)
|
||||
const lastPos = getNodeAlignPosition(last)
|
||||
|
||||
// For entry nodes, we need to subtract the offset from width since lastPos already includes it
|
||||
const lastIsEntryNode = isEntryNode(last)
|
||||
const lastNodeWidth = lastIsEntryNode ? last.width! - ENTRY_NODE_WRAPPER_OFFSET.x : last.width!
|
||||
|
||||
const helpLine = {
|
||||
top: firstPos.y,
|
||||
left: firstPos.x,
|
||||
width: lastPos.x + lastNodeWidth - firstPos.x,
|
||||
}
|
||||
|
||||
if (nodeAlignPos.x < firstPos.x) {
|
||||
const firstIsEntryNode = isEntryNode(first)
|
||||
const firstNodeWidth = firstIsEntryNode ? first.width! - ENTRY_NODE_WRAPPER_OFFSET.x : first.width!
|
||||
helpLine.left = nodeAlignPos.x
|
||||
helpLine.width = firstPos.x + firstNodeWidth - nodeAlignPos.x
|
||||
}
|
||||
|
||||
if (nodeAlignPos.x > lastPos.x) {
|
||||
const nodeIsEntryNode = isEntryNode(node)
|
||||
const nodeWidth = nodeIsEntryNode ? node.width! - ENTRY_NODE_WRAPPER_OFFSET.x : node.width!
|
||||
helpLine.width = nodeAlignPos.x + nodeWidth - firstPos.x
|
||||
}
|
||||
|
||||
setHelpLineHorizontal(helpLine)
|
||||
}
|
||||
else {
|
||||
setHelpLineHorizontal()
|
||||
}
|
||||
|
||||
const showVerticalHelpLineNodes = nodes.filter((n) => {
|
||||
if (n.id === node.id)
|
||||
return false
|
||||
if (n.data.isInIteration)
|
||||
return false
|
||||
if (n.data.isInLoop)
|
||||
return false
|
||||
|
||||
// Get actual alignment position for comparison node
|
||||
const nAlignPos = getNodeAlignPosition(n)
|
||||
const nX = Math.ceil(nAlignPos.x)
|
||||
const nodeX = Math.ceil(nodeAlignPos.x)
|
||||
|
||||
if (nX - nodeX < 5 && nX - nodeX > -5)
|
||||
return true
|
||||
|
||||
return false
|
||||
}).sort((a, b) => {
|
||||
const aPos = getNodeAlignPosition(a)
|
||||
const bPos = getNodeAlignPosition(b)
|
||||
return aPos.x - bPos.x
|
||||
})
|
||||
const showVerticalHelpLineNodesLength = showVerticalHelpLineNodes.length
|
||||
|
||||
if (showVerticalHelpLineNodesLength > 0) {
|
||||
const first = showVerticalHelpLineNodes[0]
|
||||
const last = showVerticalHelpLineNodes[showVerticalHelpLineNodesLength - 1]
|
||||
|
||||
// Use actual alignment positions for help line rendering
|
||||
const firstPos = getNodeAlignPosition(first)
|
||||
const lastPos = getNodeAlignPosition(last)
|
||||
|
||||
// For entry nodes, we need to subtract the offset from height since lastPos already includes it
|
||||
const lastIsEntryNode = isEntryNode(last)
|
||||
const lastNodeHeight = lastIsEntryNode ? last.height! - ENTRY_NODE_WRAPPER_OFFSET.y : last.height!
|
||||
|
||||
const helpLine = {
|
||||
top: firstPos.y,
|
||||
left: firstPos.x,
|
||||
height: lastPos.y + lastNodeHeight - firstPos.y,
|
||||
}
|
||||
|
||||
if (nodeAlignPos.y < firstPos.y) {
|
||||
const firstIsEntryNode = isEntryNode(first)
|
||||
const firstNodeHeight = firstIsEntryNode ? first.height! - ENTRY_NODE_WRAPPER_OFFSET.y : first.height!
|
||||
helpLine.top = nodeAlignPos.y
|
||||
helpLine.height = firstPos.y + firstNodeHeight - nodeAlignPos.y
|
||||
}
|
||||
|
||||
if (nodeAlignPos.y > lastPos.y) {
|
||||
const nodeIsEntryNode = isEntryNode(node)
|
||||
const nodeHeight = nodeIsEntryNode ? node.height! - ENTRY_NODE_WRAPPER_OFFSET.y : node.height!
|
||||
helpLine.height = nodeAlignPos.y + nodeHeight - firstPos.y
|
||||
}
|
||||
|
||||
setHelpLineVertical(helpLine)
|
||||
}
|
||||
else {
|
||||
setHelpLineVertical()
|
||||
}
|
||||
|
||||
return {
|
||||
showHorizontalHelpLineNodes,
|
||||
showVerticalHelpLineNodes,
|
||||
}
|
||||
}, [store, workflowStore, getNodeAlignPosition])
|
||||
|
||||
return {
|
||||
handleSetHelpline,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { fetchNodeInspectVars } from '@/service/workflow'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
import { VarInInspectType } from '@/types/workflow'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
isConversationVar,
|
||||
isENV,
|
||||
isSystemVar,
|
||||
toNodeOutputVars,
|
||||
} from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import { produce } from 'immer'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { useNodesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-nodes-interactions-without-sync'
|
||||
import { useEdgesInteractionsWithoutSync } from '@/app/components/workflow/hooks/use-edges-interactions-without-sync'
|
||||
import type { FlowType } from '@/types/common'
|
||||
import useFLow from '@/service/use-flow'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import type { SchemaTypeDefinition } from '@/service/use-common'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
|
||||
type Params = {
|
||||
flowId: string
|
||||
flowType: FlowType
|
||||
}
|
||||
export const useInspectVarsCrudCommon = ({
|
||||
flowId,
|
||||
flowType,
|
||||
}: Params) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const store = useStoreApi()
|
||||
const {
|
||||
useInvalidateConversationVarValues,
|
||||
useInvalidateSysVarValues,
|
||||
useResetConversationVar,
|
||||
useResetToLastRunValue,
|
||||
useDeleteAllInspectorVars,
|
||||
useDeleteNodeInspectorVars,
|
||||
useDeleteInspectVar,
|
||||
useEditInspectorVar,
|
||||
} = useFLow({ flowType })
|
||||
const invalidateConversationVarValues = useInvalidateConversationVarValues(flowId)
|
||||
const { mutateAsync: doResetConversationVar } = useResetConversationVar(flowId)
|
||||
const { mutateAsync: doResetToLastRunValue } = useResetToLastRunValue(flowId)
|
||||
const invalidateSysVarValues = useInvalidateSysVarValues(flowId)
|
||||
|
||||
const { mutateAsync: doDeleteAllInspectorVars } = useDeleteAllInspectorVars(flowId)
|
||||
const { mutate: doDeleteNodeInspectorVars } = useDeleteNodeInspectorVars(flowId)
|
||||
const { mutate: doDeleteInspectVar } = useDeleteInspectVar(flowId)
|
||||
|
||||
const { mutateAsync: doEditInspectorVar } = useEditInspectorVar(flowId)
|
||||
const { handleCancelNodeSuccessStatus } = useNodesInteractionsWithoutSync()
|
||||
const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync()
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
|
||||
const getNodeInspectVars = useCallback((nodeId: string) => {
|
||||
const { nodesWithInspectVars } = workflowStore.getState()
|
||||
const node = nodesWithInspectVars.find(node => node.nodeId === nodeId)
|
||||
return node
|
||||
}, [workflowStore])
|
||||
|
||||
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 = useCallback(async (selector: ValueSelector, schemaTypeDefinitions: SchemaTypeDefinition[]) => {
|
||||
const {
|
||||
setNodeInspectVars,
|
||||
dataSourceList,
|
||||
} = workflowStore.getState()
|
||||
const nodeId = selector[0]
|
||||
const isSystemVar = nodeId === 'sys'
|
||||
const isConversationVar = nodeId === 'conversation'
|
||||
if (isSystemVar) {
|
||||
invalidateSysVarValues()
|
||||
return
|
||||
}
|
||||
if (isConversationVar) {
|
||||
invalidateConversationVarValues()
|
||||
return
|
||||
}
|
||||
const { getNodes } = store.getState()
|
||||
const nodeArr = getNodes()
|
||||
const currentNode = nodeArr.find(node => node.id === nodeId)
|
||||
const allPluginInfoList = {
|
||||
buildInTools: buildInTools || [],
|
||||
customTools: customTools || [],
|
||||
workflowTools: workflowTools || [],
|
||||
mcpTools: mcpTools || [],
|
||||
dataSourceList: dataSourceList || [],
|
||||
}
|
||||
const currentNodeOutputVars = toNodeOutputVars([currentNode], false, () => true, [], [], [], allPluginInfoList, schemaTypeDefinitions)
|
||||
const vars = await fetchNodeInspectVars(flowType, flowId, nodeId)
|
||||
const varsWithSchemaType = vars.map((varItem) => {
|
||||
const schemaType = currentNodeOutputVars[0]?.vars.find(v => v.variable === varItem.name)?.schemaType || ''
|
||||
return {
|
||||
...varItem,
|
||||
schemaType,
|
||||
}
|
||||
})
|
||||
setNodeInspectVars(nodeId, varsWithSchemaType)
|
||||
}, [workflowStore, flowType, flowId, invalidateSysVarValues, invalidateConversationVarValues, buildInTools, customTools, workflowTools, mcpTools])
|
||||
|
||||
// after last run would call this
|
||||
const appendNodeInspectVars = useCallback((nodeId: string, payload: VarInInspect[], allNodes: Node[]) => {
|
||||
const {
|
||||
nodesWithInspectVars,
|
||||
setNodesWithInspectVars,
|
||||
} = workflowStore.getState()
|
||||
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.unshift({
|
||||
nodeId,
|
||||
nodeType: nodeInfo.data.type,
|
||||
title: nodeInfo.data.title,
|
||||
vars: payload,
|
||||
nodePayload: nodeInfo.data,
|
||||
})
|
||||
}
|
||||
else {
|
||||
draft[index].vars = payload
|
||||
// put the node to the topAdd commentMore actions
|
||||
draft.unshift(draft.splice(index, 1)[0])
|
||||
}
|
||||
}
|
||||
})
|
||||
setNodesWithInspectVars(nodes)
|
||||
handleCancelNodeSuccessStatus(nodeId)
|
||||
}, [workflowStore, handleCancelNodeSuccessStatus])
|
||||
|
||||
const hasNodeInspectVar = useCallback((nodeId: string, varId: string) => {
|
||||
const { nodesWithInspectVars } = workflowStore.getState()
|
||||
const targetNode = nodesWithInspectVars.find(item => item.nodeId === nodeId)
|
||||
if (!targetNode || !targetNode.vars)
|
||||
return false
|
||||
return targetNode.vars.some(item => item.id === varId)
|
||||
}, [workflowStore])
|
||||
|
||||
const deleteInspectVar = useCallback(async (nodeId: string, varId: string) => {
|
||||
const { deleteInspectVar } = workflowStore.getState()
|
||||
if (hasNodeInspectVar(nodeId, varId)) {
|
||||
await doDeleteInspectVar(varId)
|
||||
deleteInspectVar(nodeId, varId)
|
||||
}
|
||||
}, [doDeleteInspectVar, workflowStore, hasNodeInspectVar])
|
||||
|
||||
const resetConversationVar = useCallback(async (varId: string) => {
|
||||
await doResetConversationVar(varId)
|
||||
invalidateConversationVarValues()
|
||||
}, [doResetConversationVar, invalidateConversationVarValues])
|
||||
|
||||
const deleteNodeInspectorVars = useCallback(async (nodeId: string) => {
|
||||
const { deleteNodeInspectVars } = workflowStore.getState()
|
||||
if (hasNodeInspectVars(nodeId)) {
|
||||
await doDeleteNodeInspectorVars(nodeId)
|
||||
deleteNodeInspectVars(nodeId)
|
||||
}
|
||||
}, [doDeleteNodeInspectorVars, workflowStore, hasNodeInspectVars])
|
||||
|
||||
const deleteAllInspectorVars = useCallback(async () => {
|
||||
const { deleteAllInspectVars } = workflowStore.getState()
|
||||
await doDeleteAllInspectorVars()
|
||||
await invalidateConversationVarValues()
|
||||
await invalidateSysVarValues()
|
||||
deleteAllInspectVars()
|
||||
handleEdgeCancelRunningStatus()
|
||||
}, [doDeleteAllInspectorVars, invalidateConversationVarValues, invalidateSysVarValues, workflowStore, handleEdgeCancelRunningStatus])
|
||||
|
||||
const editInspectVarValue = useCallback(async (nodeId: string, varId: string, value: any) => {
|
||||
const { setInspectVarValue } = workflowStore.getState()
|
||||
await doEditInspectorVar({
|
||||
varId,
|
||||
value,
|
||||
})
|
||||
setInspectVarValue(nodeId, varId, value)
|
||||
if (nodeId === VarInInspectType.conversation)
|
||||
invalidateConversationVarValues()
|
||||
if (nodeId === VarInInspectType.system)
|
||||
invalidateSysVarValues()
|
||||
}, [doEditInspectorVar, invalidateConversationVarValues, invalidateSysVarValues, workflowStore])
|
||||
|
||||
const renameInspectVarName = useCallback(async (nodeId: string, oldName: string, newName: string) => {
|
||||
const { renameInspectVarName } = workflowStore.getState()
|
||||
const varId = getVarId(nodeId, oldName)
|
||||
if (!varId)
|
||||
return
|
||||
|
||||
const newSelector = [nodeId, newName]
|
||||
await doEditInspectorVar({
|
||||
varId,
|
||||
name: newName,
|
||||
})
|
||||
renameInspectVarName(nodeId, varId, newSelector)
|
||||
}, [doEditInspectorVar, getVarId, workflowStore])
|
||||
|
||||
const isInspectVarEdited = useCallback((nodeId: string, name: string) => {
|
||||
const inspectVar = getInspectVar(nodeId, name)
|
||||
if (!inspectVar)
|
||||
return false
|
||||
|
||||
return inspectVar.edited
|
||||
}, [getInspectVar])
|
||||
|
||||
const resetToLastRunVar = useCallback(async (nodeId: string, varId: string) => {
|
||||
const { resetToLastRunVar } = workflowStore.getState()
|
||||
const isSysVar = nodeId === 'sys'
|
||||
const data = await doResetToLastRunValue(varId)
|
||||
|
||||
if (isSysVar)
|
||||
invalidateSysVarValues()
|
||||
else
|
||||
resetToLastRunVar(nodeId, varId, data.value)
|
||||
}, [doResetToLastRunValue, invalidateSysVarValues, workflowStore])
|
||||
|
||||
return {
|
||||
hasNodeInspectVars,
|
||||
hasSetInspectVar,
|
||||
fetchInspectVarValue,
|
||||
editInspectVarValue,
|
||||
renameInspectVarName,
|
||||
appendNodeInspectVars,
|
||||
deleteInspectVar,
|
||||
deleteNodeInspectorVars,
|
||||
deleteAllInspectorVars,
|
||||
isInspectVarEdited,
|
||||
resetToLastRunVar,
|
||||
invalidateSysVarValues,
|
||||
resetConversationVar,
|
||||
invalidateConversationVarValues,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useStore } from '../store'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import {
|
||||
useConversationVarValues,
|
||||
useSysVarValues,
|
||||
} from '@/service/use-workflow'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { produce } from 'immer'
|
||||
import { BlockEnum } from '../types'
|
||||
|
||||
const varsAppendStartNodeKeys = ['query', 'files']
|
||||
const useInspectVarsCrud = () => {
|
||||
const partOfNodesWithInspectVars = useStore(s => s.nodesWithInspectVars)
|
||||
const configsMap = useHooksStore(s => s.configsMap)
|
||||
const isRagPipeline = configsMap?.flowType === FlowType.ragPipeline
|
||||
const { data: conversationVars } = useConversationVarValues(configsMap?.flowType, !isRagPipeline ? configsMap?.flowId : '')
|
||||
const { data: allSystemVars } = useSysVarValues(configsMap?.flowType, !isRagPipeline ? configsMap?.flowId : '')
|
||||
const { varsAppendStartNode, systemVars } = (() => {
|
||||
if(allSystemVars?.length === 0)
|
||||
return { varsAppendStartNode: [], systemVars: [] }
|
||||
const varsAppendStartNode = allSystemVars?.filter(({ name }) => varsAppendStartNodeKeys.includes(name)) || []
|
||||
const systemVars = allSystemVars?.filter(({ name }) => !varsAppendStartNodeKeys.includes(name)) || []
|
||||
return { varsAppendStartNode, systemVars }
|
||||
})()
|
||||
const nodesWithInspectVars = (() => {
|
||||
if(!partOfNodesWithInspectVars || partOfNodesWithInspectVars.length === 0)
|
||||
return []
|
||||
|
||||
const nodesWithInspectVars = produce(partOfNodesWithInspectVars, (draft) => {
|
||||
draft.forEach((nodeWithVars) => {
|
||||
if(nodeWithVars.nodeType === BlockEnum.Start)
|
||||
nodeWithVars.vars = [...nodeWithVars.vars, ...varsAppendStartNode]
|
||||
})
|
||||
})
|
||||
return nodesWithInspectVars
|
||||
})()
|
||||
const hasNodeInspectVars = useHooksStore(s => s.hasNodeInspectVars)
|
||||
const hasSetInspectVar = useHooksStore(s => s.hasSetInspectVar)
|
||||
const fetchInspectVarValue = useHooksStore(s => s.fetchInspectVarValue)
|
||||
const editInspectVarValue = useHooksStore(s => s.editInspectVarValue)
|
||||
const renameInspectVarName = useHooksStore(s => s.renameInspectVarName)
|
||||
const appendNodeInspectVars = useHooksStore(s => s.appendNodeInspectVars)
|
||||
const deleteInspectVar = useHooksStore(s => s.deleteInspectVar)
|
||||
const deleteNodeInspectorVars = useHooksStore(s => s.deleteNodeInspectorVars)
|
||||
const deleteAllInspectorVars = useHooksStore(s => s.deleteAllInspectorVars)
|
||||
const isInspectVarEdited = useHooksStore(s => s.isInspectVarEdited)
|
||||
const resetToLastRunVar = useHooksStore(s => s.resetToLastRunVar)
|
||||
const invalidateSysVarValues = useHooksStore(s => s.invalidateSysVarValues)
|
||||
const resetConversationVar = useHooksStore(s => s.resetConversationVar)
|
||||
const invalidateConversationVarValues = useHooksStore(s => s.invalidateConversationVarValues)
|
||||
|
||||
return {
|
||||
conversationVars: conversationVars || [],
|
||||
systemVars: systemVars || [],
|
||||
nodesWithInspectVars,
|
||||
hasNodeInspectVars,
|
||||
hasSetInspectVar,
|
||||
fetchInspectVarValue,
|
||||
editInspectVarValue,
|
||||
renameInspectVarName,
|
||||
appendNodeInspectVars,
|
||||
deleteInspectVar,
|
||||
deleteNodeInspectorVars,
|
||||
deleteAllInspectorVars,
|
||||
isInspectVarEdited,
|
||||
resetToLastRunVar,
|
||||
invalidateSysVarValues,
|
||||
resetConversationVar,
|
||||
invalidateConversationVarValues,
|
||||
}
|
||||
}
|
||||
|
||||
export default useInspectVarsCrud
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback } from 'react'
|
||||
import { produce } from 'immer'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import type { SyncCallback } from './use-nodes-sync-draft'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
import { useNodesReadOnly } from './use-workflow'
|
||||
|
||||
type NodeDataUpdatePayload = {
|
||||
id: string
|
||||
data: Record<string, any>
|
||||
}
|
||||
|
||||
export const useNodeDataUpdate = () => {
|
||||
const store = useStoreApi()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
|
||||
const handleNodeDataUpdate = useCallback(({ id, data }: NodeDataUpdatePayload) => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const newNodes = produce(getNodes(), (draft) => {
|
||||
const currentNode = draft.find(node => node.id === id)!
|
||||
|
||||
if (currentNode)
|
||||
currentNode.data = { ...currentNode.data, ...data }
|
||||
})
|
||||
setNodes(newNodes)
|
||||
}, [store])
|
||||
|
||||
const handleNodeDataUpdateWithSyncDraft = useCallback((
|
||||
payload: NodeDataUpdatePayload,
|
||||
options?: {
|
||||
sync?: boolean
|
||||
notRefreshWhenSyncError?: boolean
|
||||
callback?: SyncCallback
|
||||
},
|
||||
) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
handleNodeDataUpdate(payload)
|
||||
handleSyncWorkflowDraft(options?.sync, options?.notRefreshWhenSyncError, options?.callback)
|
||||
}, [handleSyncWorkflowDraft, handleNodeDataUpdate, getNodesReadOnly])
|
||||
|
||||
return {
|
||||
handleNodeDataUpdate,
|
||||
handleNodeDataUpdateWithSyncDraft,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { BlockEnum, type CommonNodeType } from '../types'
|
||||
import type { ToolNodeType } from '../nodes/tool/types'
|
||||
import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types'
|
||||
import type { DataSourceNodeType } from '../nodes/data-source/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
useInvalidToolsByType,
|
||||
} from '@/service/use-tools'
|
||||
import {
|
||||
useAllTriggerPlugins,
|
||||
useInvalidateAllTriggerPlugins,
|
||||
} from '@/service/use-triggers'
|
||||
import { useInvalidDataSourceList } from '@/service/use-pipeline'
|
||||
import { useStore } from '../store'
|
||||
import { canFindTool } from '@/utils'
|
||||
|
||||
type InstallationState = {
|
||||
isChecking: boolean
|
||||
isMissing: boolean
|
||||
uniqueIdentifier?: string
|
||||
canInstall: boolean
|
||||
onInstallSuccess: () => void
|
||||
shouldDim: boolean
|
||||
}
|
||||
|
||||
const useToolInstallation = (data: ToolNodeType): InstallationState => {
|
||||
const builtInQuery = useAllBuiltInTools()
|
||||
const customQuery = useAllCustomTools()
|
||||
const workflowQuery = useAllWorkflowTools()
|
||||
const mcpQuery = useAllMCPTools()
|
||||
const invalidateTools = useInvalidToolsByType(data.provider_type)
|
||||
|
||||
const collectionInfo = useMemo(() => {
|
||||
switch (data.provider_type) {
|
||||
case CollectionType.builtIn:
|
||||
return {
|
||||
list: builtInQuery.data,
|
||||
isLoading: builtInQuery.isLoading,
|
||||
}
|
||||
case CollectionType.custom:
|
||||
return {
|
||||
list: customQuery.data,
|
||||
isLoading: customQuery.isLoading,
|
||||
}
|
||||
case CollectionType.workflow:
|
||||
return {
|
||||
list: workflowQuery.data,
|
||||
isLoading: workflowQuery.isLoading,
|
||||
}
|
||||
case CollectionType.mcp:
|
||||
return {
|
||||
list: mcpQuery.data,
|
||||
isLoading: mcpQuery.isLoading,
|
||||
}
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}, [
|
||||
builtInQuery.data,
|
||||
builtInQuery.isLoading,
|
||||
customQuery.data,
|
||||
customQuery.isLoading,
|
||||
data.provider_type,
|
||||
mcpQuery.data,
|
||||
mcpQuery.isLoading,
|
||||
workflowQuery.data,
|
||||
workflowQuery.isLoading,
|
||||
])
|
||||
|
||||
const collection = collectionInfo?.list
|
||||
const isLoading = collectionInfo?.isLoading ?? false
|
||||
const isResolved = !!collectionInfo && !isLoading
|
||||
|
||||
const matchedCollection = useMemo(() => {
|
||||
if (!collection || !collection.length)
|
||||
return undefined
|
||||
|
||||
return collection.find((toolWithProvider) => {
|
||||
if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id)
|
||||
return true
|
||||
if (canFindTool(toolWithProvider.id, data.provider_id))
|
||||
return true
|
||||
if (toolWithProvider.name === data.provider_name)
|
||||
return true
|
||||
return false
|
||||
})
|
||||
}, [collection, data.plugin_id, data.provider_id, data.provider_name])
|
||||
|
||||
const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id
|
||||
const canInstall = Boolean(data.plugin_unique_identifier)
|
||||
|
||||
const onInstallSuccess = useCallback(() => {
|
||||
if (invalidateTools)
|
||||
invalidateTools()
|
||||
}, [invalidateTools])
|
||||
|
||||
const shouldDim = (!!collectionInfo && !isResolved) || (isResolved && !matchedCollection)
|
||||
|
||||
return {
|
||||
isChecking: !!collectionInfo && !isResolved,
|
||||
isMissing: isResolved && !matchedCollection,
|
||||
uniqueIdentifier,
|
||||
canInstall,
|
||||
onInstallSuccess,
|
||||
shouldDim,
|
||||
}
|
||||
}
|
||||
|
||||
const useTriggerInstallation = (data: PluginTriggerNodeType): InstallationState => {
|
||||
const triggerPluginsQuery = useAllTriggerPlugins()
|
||||
const invalidateTriggers = useInvalidateAllTriggerPlugins()
|
||||
|
||||
const triggerProviders = triggerPluginsQuery.data
|
||||
const isLoading = triggerPluginsQuery.isLoading
|
||||
|
||||
const matchedProvider = useMemo(() => {
|
||||
if (!triggerProviders || !triggerProviders.length)
|
||||
return undefined
|
||||
|
||||
return triggerProviders.find(provider =>
|
||||
provider.name === data.provider_name
|
||||
|| provider.id === data.provider_id
|
||||
|| (data.plugin_id && provider.plugin_id === data.plugin_id),
|
||||
)
|
||||
}, [
|
||||
data.plugin_id,
|
||||
data.provider_id,
|
||||
data.provider_name,
|
||||
triggerProviders,
|
||||
])
|
||||
|
||||
const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id
|
||||
const canInstall = Boolean(data.plugin_unique_identifier)
|
||||
|
||||
const onInstallSuccess = useCallback(() => {
|
||||
invalidateTriggers()
|
||||
}, [invalidateTriggers])
|
||||
|
||||
const shouldDim = isLoading || (!isLoading && !!triggerProviders && !matchedProvider)
|
||||
|
||||
return {
|
||||
isChecking: isLoading,
|
||||
isMissing: !isLoading && !!triggerProviders && !matchedProvider,
|
||||
uniqueIdentifier,
|
||||
canInstall,
|
||||
onInstallSuccess,
|
||||
shouldDim,
|
||||
}
|
||||
}
|
||||
|
||||
const useDataSourceInstallation = (data: DataSourceNodeType): InstallationState => {
|
||||
const dataSourceList = useStore(s => s.dataSourceList)
|
||||
const invalidateDataSourceList = useInvalidDataSourceList()
|
||||
|
||||
const matchedPlugin = useMemo(() => {
|
||||
if (!dataSourceList || !dataSourceList.length)
|
||||
return undefined
|
||||
|
||||
return dataSourceList.find((item) => {
|
||||
if (data.plugin_unique_identifier && item.plugin_unique_identifier === data.plugin_unique_identifier)
|
||||
return true
|
||||
if (data.plugin_id && item.plugin_id === data.plugin_id)
|
||||
return true
|
||||
if (data.provider_name && item.provider === data.provider_name)
|
||||
return true
|
||||
return false
|
||||
})
|
||||
}, [data.plugin_id, data.plugin_unique_identifier, data.provider_name, dataSourceList])
|
||||
|
||||
const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id
|
||||
const canInstall = Boolean(data.plugin_unique_identifier)
|
||||
|
||||
const onInstallSuccess = useCallback(() => {
|
||||
invalidateDataSourceList()
|
||||
}, [invalidateDataSourceList])
|
||||
|
||||
const hasLoadedList = dataSourceList !== undefined
|
||||
|
||||
const shouldDim = !hasLoadedList || (hasLoadedList && !matchedPlugin)
|
||||
|
||||
return {
|
||||
isChecking: !hasLoadedList,
|
||||
isMissing: hasLoadedList && !matchedPlugin,
|
||||
uniqueIdentifier,
|
||||
canInstall,
|
||||
onInstallSuccess,
|
||||
shouldDim,
|
||||
}
|
||||
}
|
||||
|
||||
export const useNodePluginInstallation = (data: CommonNodeType): InstallationState => {
|
||||
const toolInstallation = useToolInstallation(data as ToolNodeType)
|
||||
const triggerInstallation = useTriggerInstallation(data as PluginTriggerNodeType)
|
||||
const dataSourceInstallation = useDataSourceInstallation(data as DataSourceNodeType)
|
||||
|
||||
switch (data.type as BlockEnum) {
|
||||
case BlockEnum.Tool:
|
||||
return toolInstallation
|
||||
case BlockEnum.TriggerPlugin:
|
||||
return triggerInstallation
|
||||
case BlockEnum.DataSource:
|
||||
return dataSourceInstallation
|
||||
default:
|
||||
return {
|
||||
isChecking: false,
|
||||
isMissing: false,
|
||||
uniqueIdentifier: undefined,
|
||||
canInstall: false,
|
||||
onInstallSuccess: () => undefined,
|
||||
shouldDim: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
useIsChatMode,
|
||||
useWorkflow,
|
||||
useWorkflowVariables,
|
||||
} from '@/app/components/workflow/hooks'
|
||||
import { BlockEnum, type Node, type NodeOutPutVar, type ValueSelector, type Var } from '@/app/components/workflow/types'
|
||||
type Params = {
|
||||
onlyLeafNodeVar?: boolean
|
||||
hideEnv?: boolean
|
||||
hideChatVar?: boolean
|
||||
filterVar: (payload: Var, selector: ValueSelector) => boolean
|
||||
passedInAvailableNodes?: Node[]
|
||||
}
|
||||
|
||||
const getNodeInfo = (nodeId: string, nodes: Node[]) => {
|
||||
const allNodes = nodes
|
||||
const node = allNodes.find(n => n.id === nodeId)
|
||||
const isInIteration = !!node?.data.isInIteration
|
||||
const isInLoop = !!node?.data.isInLoop
|
||||
const parentNodeId = node?.parentId
|
||||
const parentNode = allNodes.find(n => n.id === parentNodeId)
|
||||
return {
|
||||
node,
|
||||
isInIteration,
|
||||
isInLoop,
|
||||
parentNode,
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: loop type?
|
||||
const useNodesAvailableVarList = (nodes: Node[], {
|
||||
onlyLeafNodeVar,
|
||||
filterVar,
|
||||
hideEnv = false,
|
||||
hideChatVar = false,
|
||||
passedInAvailableNodes,
|
||||
}: Params = {
|
||||
onlyLeafNodeVar: false,
|
||||
filterVar: () => true,
|
||||
}) => {
|
||||
const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
|
||||
const { getNodeAvailableVars } = useWorkflowVariables()
|
||||
const isChatMode = useIsChatMode()
|
||||
|
||||
const nodeAvailabilityMap: { [key: string ]: { availableVars: NodeOutPutVar[], availableNodes: Node[] } } = {}
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const nodeId = node.id
|
||||
const availableNodes = passedInAvailableNodes || (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranchIncludeParent(nodeId))
|
||||
if (node.data.type === BlockEnum.Loop)
|
||||
availableNodes.push(node)
|
||||
|
||||
const {
|
||||
parentNode: iterationNode,
|
||||
} = getNodeInfo(nodeId, nodes)
|
||||
|
||||
const availableVars = getNodeAvailableVars({
|
||||
parentNode: iterationNode,
|
||||
beforeNodes: availableNodes,
|
||||
isChatMode,
|
||||
filterVar,
|
||||
hideEnv,
|
||||
hideChatVar,
|
||||
})
|
||||
const result = {
|
||||
node,
|
||||
availableVars,
|
||||
availableNodes,
|
||||
}
|
||||
nodeAvailabilityMap[nodeId] = result
|
||||
})
|
||||
return nodeAvailabilityMap
|
||||
}
|
||||
|
||||
export const useGetNodesAvailableVarList = () => {
|
||||
const { getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent } = useWorkflow()
|
||||
const { getNodeAvailableVars } = useWorkflowVariables()
|
||||
const isChatMode = useIsChatMode()
|
||||
const getNodesAvailableVarList = useCallback((nodes: Node[], {
|
||||
onlyLeafNodeVar,
|
||||
filterVar,
|
||||
hideEnv,
|
||||
hideChatVar,
|
||||
passedInAvailableNodes,
|
||||
}: Params = {
|
||||
onlyLeafNodeVar: false,
|
||||
filterVar: () => true,
|
||||
}) => {
|
||||
const nodeAvailabilityMap: { [key: string ]: { availableVars: NodeOutPutVar[], availableNodes: Node[] } } = {}
|
||||
|
||||
nodes.forEach((node) => {
|
||||
const nodeId = node.id
|
||||
const availableNodes = passedInAvailableNodes || (onlyLeafNodeVar ? getTreeLeafNodes(nodeId) : getBeforeNodesInSameBranchIncludeParent(nodeId))
|
||||
if (node.data.type === BlockEnum.Loop)
|
||||
availableNodes.push(node)
|
||||
|
||||
const {
|
||||
parentNode: iterationNode,
|
||||
} = getNodeInfo(nodeId, nodes)
|
||||
|
||||
const availableVars = getNodeAvailableVars({
|
||||
parentNode: iterationNode,
|
||||
beforeNodes: availableNodes,
|
||||
isChatMode,
|
||||
filterVar,
|
||||
hideEnv,
|
||||
hideChatVar,
|
||||
})
|
||||
const result = {
|
||||
node,
|
||||
availableVars,
|
||||
availableNodes,
|
||||
}
|
||||
nodeAvailabilityMap[nodeId] = result
|
||||
})
|
||||
return nodeAvailabilityMap
|
||||
}, [getTreeLeafNodes, getBeforeNodesInSameBranchIncludeParent, getNodeAvailableVars, isChatMode])
|
||||
return {
|
||||
getNodesAvailableVarList,
|
||||
}
|
||||
}
|
||||
|
||||
export default useNodesAvailableVarList
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback } from 'react'
|
||||
import { produce } from 'immer'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { NodeRunningStatus } from '../types'
|
||||
|
||||
export const useNodesInteractionsWithoutSync = () => {
|
||||
const store = useStoreApi()
|
||||
|
||||
const handleNodeCancelRunningStatus = useCallback(() => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
node.data._runningStatus = undefined
|
||||
node.data._waitingRun = false
|
||||
})
|
||||
})
|
||||
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,
|
||||
}
|
||||
}
|
||||
2016
dify/web/app/components/workflow/hooks/use-nodes-interactions.ts
Normal file
2016
dify/web/app/components/workflow/hooks/use-nodes-interactions.ts
Normal file
File diff suppressed because it is too large
Load Diff
96
dify/web/app/components/workflow/hooks/use-nodes-layout.ts
Normal file
96
dify/web/app/components/workflow/hooks/use-nodes-layout.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useCallback } from 'react'
|
||||
import ELK from 'elkjs/lib/elk.bundled.js'
|
||||
import {
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import type {
|
||||
Edge,
|
||||
Node,
|
||||
} from '../types'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import { AUTO_LAYOUT_OFFSET } from '../constants'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
|
||||
const layoutOptions = {
|
||||
'elk.algorithm': 'layered',
|
||||
'elk.direction': 'RIGHT',
|
||||
'elk.layered.spacing.nodeNodeBetweenLayers': '60',
|
||||
'elk.spacing.nodeNode': '40',
|
||||
'elk.layered.nodePlacement.strategy': 'SIMPLE',
|
||||
}
|
||||
|
||||
const elk = new ELK()
|
||||
|
||||
export const getLayoutedNodes = async (nodes: Node[], edges: Edge[]) => {
|
||||
const graph = {
|
||||
id: 'root',
|
||||
layoutOptions,
|
||||
children: nodes.map((n) => {
|
||||
return {
|
||||
...n,
|
||||
width: n.width ?? 150,
|
||||
height: n.height ?? 50,
|
||||
targetPosition: 'left',
|
||||
sourcePosition: 'right',
|
||||
}
|
||||
}),
|
||||
edges: cloneDeep(edges),
|
||||
}
|
||||
|
||||
const layoutedGraph = await elk.layout(graph as any)
|
||||
const layoutedNodes = nodes.map((node) => {
|
||||
const layoutedNode = layoutedGraph.children?.find(
|
||||
lgNode => lgNode.id === node.id,
|
||||
)
|
||||
|
||||
return {
|
||||
...node,
|
||||
position: {
|
||||
x: (layoutedNode?.x ?? 0) + AUTO_LAYOUT_OFFSET.x,
|
||||
y: (layoutedNode?.y ?? 0) + AUTO_LAYOUT_OFFSET.y,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
layoutedNodes,
|
||||
}
|
||||
}
|
||||
|
||||
export const useNodesLayout = () => {
|
||||
const store = useStoreApi()
|
||||
const reactflow = useReactFlow()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
|
||||
const handleNodesLayout = useCallback(async () => {
|
||||
workflowStore.setState({ nodeAnimation: true })
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const { setViewport } = reactflow
|
||||
const nodes = getNodes()
|
||||
const {
|
||||
layoutedNodes,
|
||||
} = await getLayoutedNodes(nodes, edges)
|
||||
|
||||
setNodes(layoutedNodes)
|
||||
const zoom = 0.7
|
||||
setViewport({
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom,
|
||||
})
|
||||
setTimeout(() => {
|
||||
handleSyncWorkflowDraft()
|
||||
})
|
||||
}, [store, reactflow, handleSyncWorkflowDraft, workflowStore])
|
||||
|
||||
return {
|
||||
handleNodesLayout,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
import { canFindTool } from '@/utils'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
|
||||
export const useNodesMetaData = () => {
|
||||
const availableNodesMetaData = useHooksStore(s => s.availableNodesMetaData)
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
nodes: availableNodesMetaData?.nodes || [],
|
||||
nodesMap: availableNodesMetaData?.nodesMap || {},
|
||||
} as AvailableNodesMetaData
|
||||
}, [availableNodesMetaData])
|
||||
}
|
||||
|
||||
export const useNodeMetaData = (node: Node) => {
|
||||
const language = useGetLanguage()
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const dataSourceList = useStore(s => s.dataSourceList)
|
||||
const availableNodesMetaData = useNodesMetaData()
|
||||
const { data } = node
|
||||
const nodeMetaData = availableNodesMetaData.nodesMap?.[data.type]
|
||||
const author = useMemo(() => {
|
||||
if (data.type === BlockEnum.DataSource)
|
||||
return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.author
|
||||
|
||||
if (data.type === BlockEnum.Tool) {
|
||||
if (data.provider_type === CollectionType.builtIn)
|
||||
return buildInTools?.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.author
|
||||
if (data.provider_type === CollectionType.workflow)
|
||||
return workflowTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author
|
||||
return customTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.author
|
||||
}
|
||||
return nodeMetaData?.metaData.author
|
||||
}, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList])
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (data.type === BlockEnum.DataSource)
|
||||
return dataSourceList?.find(dataSource => dataSource.plugin_id === data.plugin_id)?.description[language]
|
||||
if (data.type === BlockEnum.Tool) {
|
||||
if (data.provider_type === CollectionType.builtIn)
|
||||
return buildInTools?.find(toolWithProvider => canFindTool(toolWithProvider.id, data.provider_id))?.description[language]
|
||||
if (data.provider_type === CollectionType.workflow)
|
||||
return workflowTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
|
||||
return customTools?.find(toolWithProvider => toolWithProvider.id === data.provider_id)?.description[language]
|
||||
}
|
||||
return nodeMetaData?.metaData.description
|
||||
}, [data, buildInTools, customTools, workflowTools, nodeMetaData, dataSourceList, language])
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
...nodeMetaData?.metaData,
|
||||
author,
|
||||
description,
|
||||
}
|
||||
}, [author, nodeMetaData, description])
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useStore } from '../store'
|
||||
import { useNodesReadOnly } from './use-workflow'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
|
||||
export type SyncCallback = {
|
||||
onSuccess?: () => void
|
||||
onError?: () => void
|
||||
onSettled?: () => void
|
||||
}
|
||||
|
||||
export const useNodesSyncDraft = () => {
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const debouncedSyncWorkflowDraft = useStore(s => s.debouncedSyncWorkflowDraft)
|
||||
const doSyncWorkflowDraft = useHooksStore(s => s.doSyncWorkflowDraft)
|
||||
const syncWorkflowDraftWhenPageClose = useHooksStore(s => s.syncWorkflowDraftWhenPageClose)
|
||||
|
||||
const handleSyncWorkflowDraft = useCallback((
|
||||
sync?: boolean,
|
||||
notRefreshWhenSyncError?: boolean,
|
||||
callback?: SyncCallback,
|
||||
) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
if (sync)
|
||||
doSyncWorkflowDraft(notRefreshWhenSyncError, callback)
|
||||
else
|
||||
debouncedSyncWorkflowDraft(doSyncWorkflowDraft)
|
||||
}, [debouncedSyncWorkflowDraft, doSyncWorkflowDraft, getNodesReadOnly])
|
||||
|
||||
return {
|
||||
doSyncWorkflowDraft,
|
||||
handleSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useWorkflowStore } from '../store'
|
||||
|
||||
export const usePanelInteractions = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const handlePaneContextMenu = useCallback((e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const container = document.querySelector('#workflow-container')
|
||||
const { x, y } = container!.getBoundingClientRect()
|
||||
workflowStore.setState({
|
||||
panelMenu: {
|
||||
top: e.clientY - y,
|
||||
left: e.clientX - x,
|
||||
},
|
||||
})
|
||||
}, [workflowStore])
|
||||
|
||||
const handlePaneContextmenuCancel = useCallback(() => {
|
||||
workflowStore.setState({
|
||||
panelMenu: undefined,
|
||||
})
|
||||
}, [workflowStore])
|
||||
|
||||
const handleNodeContextmenuCancel = useCallback(() => {
|
||||
workflowStore.setState({
|
||||
nodeMenu: undefined,
|
||||
})
|
||||
}, [workflowStore])
|
||||
|
||||
return {
|
||||
handlePaneContextMenu,
|
||||
handlePaneContextmenuCancel,
|
||||
handleNodeContextmenuCancel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { MouseEvent } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { produce } from 'immer'
|
||||
import type {
|
||||
OnSelectionChangeFunc,
|
||||
} from 'reactflow'
|
||||
import { useStoreApi } from 'reactflow'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import type { Node } from '../types'
|
||||
|
||||
export const useSelectionInteractions = () => {
|
||||
const store = useStoreApi()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const handleSelectionStart = useCallback(() => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
userSelectionRect,
|
||||
} = store.getState()
|
||||
|
||||
if (!userSelectionRect?.width || !userSelectionRect?.height) {
|
||||
const nodes = getNodes()
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
if (node.data._isBundled)
|
||||
node.data._isBundled = false
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
if (edge.data._isBundled)
|
||||
edge.data._isBundled = false
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}
|
||||
}, [store])
|
||||
|
||||
const handleSelectionChange = useCallback<OnSelectionChangeFunc>(({ nodes: nodesInSelection, edges: edgesInSelection }) => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
userSelectionRect,
|
||||
} = store.getState()
|
||||
|
||||
const nodes = getNodes()
|
||||
|
||||
if (!userSelectionRect?.width || !userSelectionRect?.height)
|
||||
return
|
||||
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
const nodeInSelection = nodesInSelection.find(n => n.id === node.id)
|
||||
|
||||
if (nodeInSelection)
|
||||
node.data._isBundled = true
|
||||
else
|
||||
node.data._isBundled = false
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
const edgeInSelection = edgesInSelection.find(e => e.id === edge.id)
|
||||
|
||||
if (edgeInSelection)
|
||||
edge.data._isBundled = true
|
||||
else
|
||||
edge.data._isBundled = false
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store])
|
||||
|
||||
const handleSelectionDrag = useCallback((_: MouseEvent, nodesWithDrag: Node[]) => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
|
||||
workflowStore.setState({
|
||||
nodeAnimation: false,
|
||||
})
|
||||
const nodes = getNodes()
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
const dragNode = nodesWithDrag.find(n => n.id === node.id)
|
||||
|
||||
if (dragNode)
|
||||
node.position = dragNode.position
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
}, [store, workflowStore])
|
||||
|
||||
const handleSelectionCancel = useCallback(() => {
|
||||
const {
|
||||
getNodes,
|
||||
setNodes,
|
||||
edges,
|
||||
setEdges,
|
||||
} = store.getState()
|
||||
|
||||
store.setState({
|
||||
userSelectionRect: null,
|
||||
userSelectionActive: true,
|
||||
})
|
||||
|
||||
const nodes = getNodes()
|
||||
const newNodes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
if (node.data._isBundled)
|
||||
node.data._isBundled = false
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
if (edge.data._isBundled)
|
||||
edge.data._isBundled = false
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
}, [store])
|
||||
|
||||
const handleSelectionContextMenu = useCallback((e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
if (!target.classList.contains('react-flow__nodesselection-rect'))
|
||||
return
|
||||
|
||||
e.preventDefault()
|
||||
const container = document.querySelector('#workflow-container')
|
||||
const { x, y } = container!.getBoundingClientRect()
|
||||
workflowStore.setState({
|
||||
selectionMenu: {
|
||||
top: e.clientY - y,
|
||||
left: e.clientX - x,
|
||||
},
|
||||
})
|
||||
}, [workflowStore])
|
||||
|
||||
const handleSelectionContextmenuCancel = useCallback(() => {
|
||||
workflowStore.setState({
|
||||
selectionMenu: undefined,
|
||||
})
|
||||
}, [workflowStore])
|
||||
|
||||
return {
|
||||
handleSelectionStart,
|
||||
handleSelectionChange,
|
||||
handleSelectionDrag,
|
||||
handleSelectionCancel,
|
||||
handleSelectionContextMenu,
|
||||
handleSelectionContextmenuCancel,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef,
|
||||
} from 'react'
|
||||
|
||||
export const useSerialAsyncCallback = <Args extends any[], Result = void>(
|
||||
fn: (...args: Args) => Promise<Result> | Result,
|
||||
shouldSkip?: () => boolean,
|
||||
) => {
|
||||
const queueRef = useRef<Promise<unknown>>(Promise.resolve())
|
||||
|
||||
return useCallback((...args: Args) => {
|
||||
if (shouldSkip?.())
|
||||
return Promise.resolve(undefined as Result)
|
||||
|
||||
const lastPromise = queueRef.current.catch(() => undefined)
|
||||
const nextPromise = lastPromise.then(() => fn(...args))
|
||||
queueRef.current = nextPromise
|
||||
|
||||
return nextPromise
|
||||
}, [fn, shouldSkip])
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
|
||||
export const useSetWorkflowVarsWithValue = () => {
|
||||
const fetchInspectVars = useHooksStore(s => s.fetchInspectVars)
|
||||
|
||||
return {
|
||||
fetchInspectVars,
|
||||
}
|
||||
}
|
||||
249
dify/web/app/components/workflow/hooks/use-shortcuts.ts
Normal file
249
dify/web/app/components/workflow/hooks/use-shortcuts.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useReactFlow } from 'reactflow'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { useCallback } from 'react'
|
||||
import {
|
||||
getKeyboardKeyCodeBySystem,
|
||||
isEventTargetInputArea,
|
||||
} from '../utils'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
import { useWorkflowStore } from '../store'
|
||||
import {
|
||||
useEdgesInteractions,
|
||||
useNodesInteractions,
|
||||
useNodesSyncDraft,
|
||||
useWorkflowCanvasMaximize,
|
||||
useWorkflowMoveMode,
|
||||
useWorkflowOrganize,
|
||||
} from '.'
|
||||
|
||||
export const useShortcuts = (): void => {
|
||||
const {
|
||||
handleNodesCopy,
|
||||
handleNodesPaste,
|
||||
handleNodesDuplicate,
|
||||
handleNodesDelete,
|
||||
handleHistoryBack,
|
||||
handleHistoryForward,
|
||||
dimOtherNodes,
|
||||
undimAllNodes,
|
||||
} = useNodesInteractions()
|
||||
const { shortcutsEnabled: workflowHistoryShortcutsEnabled } = useWorkflowHistoryStore()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { handleEdgeDelete } = useEdgesInteractions()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const {
|
||||
handleModeHand,
|
||||
handleModePointer,
|
||||
} = useWorkflowMoveMode()
|
||||
const { handleLayout } = useWorkflowOrganize()
|
||||
const { handleToggleMaximizeCanvas } = useWorkflowCanvasMaximize()
|
||||
|
||||
const {
|
||||
zoomTo,
|
||||
getZoom,
|
||||
fitView,
|
||||
} = useReactFlow()
|
||||
|
||||
// Zoom out to a minimum of 0.25 for shortcut
|
||||
const constrainedZoomOut = () => {
|
||||
const currentZoom = getZoom()
|
||||
const newZoom = Math.max(currentZoom - 0.1, 0.25)
|
||||
zoomTo(newZoom)
|
||||
}
|
||||
|
||||
// Zoom in to a maximum of 2 for shortcut
|
||||
const constrainedZoomIn = () => {
|
||||
const currentZoom = getZoom()
|
||||
const newZoom = Math.min(currentZoom + 0.1, 2)
|
||||
zoomTo(newZoom)
|
||||
}
|
||||
|
||||
const shouldHandleShortcut = useCallback((e: KeyboardEvent) => {
|
||||
return !isEventTargetInputArea(e.target as HTMLElement)
|
||||
}, [])
|
||||
|
||||
useKeyPress(['delete', 'backspace'], (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleNodesDelete()
|
||||
handleEdgeDelete()
|
||||
}
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.c`, (e) => {
|
||||
const { showDebugAndPreviewPanel } = workflowStore.getState()
|
||||
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
|
||||
e.preventDefault()
|
||||
handleNodesCopy()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.v`, (e) => {
|
||||
const { showDebugAndPreviewPanel } = workflowStore.getState()
|
||||
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
|
||||
e.preventDefault()
|
||||
handleNodesPaste()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.d`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleNodesDuplicate()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('alt')}.r`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
// @ts-expect-error - Dynamic property added by run-and-history component
|
||||
if (window._toggleTestRunDropdown) {
|
||||
// @ts-expect-error - Dynamic property added by run-and-history component
|
||||
window._toggleTestRunDropdown()
|
||||
}
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.z`, (e) => {
|
||||
const { showDebugAndPreviewPanel } = workflowStore.getState()
|
||||
if (shouldHandleShortcut(e) && !showDebugAndPreviewPanel) {
|
||||
e.preventDefault()
|
||||
if (workflowHistoryShortcutsEnabled)
|
||||
handleHistoryBack()
|
||||
}
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
useKeyPress(
|
||||
[`${getKeyboardKeyCodeBySystem('ctrl')}.y`, `${getKeyboardKeyCodeBySystem('ctrl')}.shift.z`],
|
||||
(e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
if (workflowHistoryShortcutsEnabled)
|
||||
handleHistoryForward()
|
||||
}
|
||||
},
|
||||
{ exactMatch: true, useCapture: true },
|
||||
)
|
||||
|
||||
useKeyPress('h', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleModeHand()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('v', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleModePointer()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.o`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
handleLayout()
|
||||
}
|
||||
}, { 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()
|
||||
fitView()
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('shift.1', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
zoomTo(1)
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress('shift.5', (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
zoomTo(0.5)
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.dash`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
constrainedZoomOut()
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.equalsign`, (e) => {
|
||||
if (shouldHandleShortcut(e)) {
|
||||
e.preventDefault()
|
||||
constrainedZoomIn()
|
||||
handleSyncWorkflowDraft()
|
||||
}
|
||||
}, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
// Shift ↓
|
||||
useKeyPress(
|
||||
'shift',
|
||||
(e) => {
|
||||
if (shouldHandleShortcut(e))
|
||||
dimOtherNodes()
|
||||
},
|
||||
{
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
events: ['keydown'],
|
||||
},
|
||||
)
|
||||
|
||||
// Shift ↑
|
||||
useKeyPress(
|
||||
(e) => {
|
||||
return e.key === 'Shift'
|
||||
},
|
||||
(e) => {
|
||||
if (shouldHandleShortcut(e))
|
||||
undimAllNodes()
|
||||
},
|
||||
{
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
events: ['keyup'],
|
||||
},
|
||||
)
|
||||
}
|
||||
202
dify/web/app/components/workflow/hooks/use-tool-icon.ts
Normal file
202
dify/web/app/components/workflow/hooks/use-tool-icon.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import type { Node, ToolWithProvider } from '../types'
|
||||
import { BlockEnum } from '../types'
|
||||
import { useStore, useWorkflowStore } from '../store'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { canFindTool } from '@/utils'
|
||||
import {
|
||||
useAllBuiltInTools,
|
||||
useAllCustomTools,
|
||||
useAllMCPTools,
|
||||
useAllWorkflowTools,
|
||||
} from '@/service/use-tools'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types'
|
||||
import type { ToolNodeType } from '../nodes/tool/types'
|
||||
import type { DataSourceNodeType } from '../nodes/data-source/types'
|
||||
import type { TriggerWithProvider } from '../block-selector/types'
|
||||
|
||||
const isTriggerPluginNode = (data: Node['data']): data is PluginTriggerNodeType => data.type === BlockEnum.TriggerPlugin
|
||||
|
||||
const isToolNode = (data: Node['data']): data is ToolNodeType => data.type === BlockEnum.Tool
|
||||
|
||||
const isDataSourceNode = (data: Node['data']): data is DataSourceNodeType => data.type === BlockEnum.DataSource
|
||||
|
||||
const findTriggerPluginIcon = (
|
||||
identifiers: (string | undefined)[],
|
||||
triggers: TriggerWithProvider[] | undefined,
|
||||
) => {
|
||||
const targetTriggers = triggers || []
|
||||
for (const identifier of identifiers) {
|
||||
if (!identifier)
|
||||
continue
|
||||
const matched = targetTriggers.find(trigger => trigger.id === identifier || canFindTool(trigger.id, identifier))
|
||||
if (matched?.icon)
|
||||
return matched.icon
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const useToolIcon = (data?: Node['data']) => {
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const dataSourceList = useStore(s => s.dataSourceList)
|
||||
const { data: triggerPlugins } = useAllTriggerPlugins()
|
||||
|
||||
const toolIcon = useMemo(() => {
|
||||
if (!data)
|
||||
return ''
|
||||
|
||||
if (isTriggerPluginNode(data)) {
|
||||
const icon = findTriggerPluginIcon(
|
||||
[
|
||||
data.plugin_id,
|
||||
data.provider_id,
|
||||
data.provider_name,
|
||||
],
|
||||
triggerPlugins,
|
||||
)
|
||||
if (icon)
|
||||
return icon
|
||||
}
|
||||
|
||||
if (isToolNode(data)) {
|
||||
let primaryCollection: ToolWithProvider[] | undefined
|
||||
switch (data.provider_type) {
|
||||
case CollectionType.custom:
|
||||
primaryCollection = customTools
|
||||
break
|
||||
case CollectionType.mcp:
|
||||
primaryCollection = mcpTools
|
||||
break
|
||||
case CollectionType.workflow:
|
||||
primaryCollection = workflowTools
|
||||
break
|
||||
case CollectionType.builtIn:
|
||||
default:
|
||||
primaryCollection = buildInTools
|
||||
break
|
||||
}
|
||||
|
||||
const collectionsToSearch = [
|
||||
primaryCollection,
|
||||
buildInTools,
|
||||
customTools,
|
||||
workflowTools,
|
||||
mcpTools,
|
||||
] as Array<ToolWithProvider[] | undefined>
|
||||
|
||||
const seen = new Set<ToolWithProvider[]>()
|
||||
for (const collection of collectionsToSearch) {
|
||||
if (!collection || seen.has(collection))
|
||||
continue
|
||||
seen.add(collection)
|
||||
const matched = collection.find((toolWithProvider) => {
|
||||
if (canFindTool(toolWithProvider.id, data.provider_id))
|
||||
return true
|
||||
if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id)
|
||||
return true
|
||||
return data.provider_name === toolWithProvider.name
|
||||
})
|
||||
if (matched?.icon)
|
||||
return matched.icon
|
||||
}
|
||||
|
||||
if (data.provider_icon)
|
||||
return data.provider_icon
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
if (isDataSourceNode(data))
|
||||
return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon || ''
|
||||
|
||||
return ''
|
||||
}, [data, dataSourceList, buildInTools, customTools, workflowTools, mcpTools, triggerPlugins])
|
||||
|
||||
return toolIcon
|
||||
}
|
||||
|
||||
export const useGetToolIcon = () => {
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const { data: triggerPlugins } = useAllTriggerPlugins()
|
||||
const workflowStore = useWorkflowStore()
|
||||
|
||||
const getToolIcon = useCallback((data: Node['data']) => {
|
||||
const {
|
||||
buildInTools: storeBuiltInTools,
|
||||
customTools: storeCustomTools,
|
||||
workflowTools: storeWorkflowTools,
|
||||
mcpTools: storeMcpTools,
|
||||
dataSourceList,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (isTriggerPluginNode(data)) {
|
||||
return findTriggerPluginIcon(
|
||||
[
|
||||
data.plugin_id,
|
||||
data.provider_id,
|
||||
data.provider_name,
|
||||
],
|
||||
triggerPlugins,
|
||||
)
|
||||
}
|
||||
|
||||
if (isToolNode(data)) {
|
||||
const primaryCollection = (() => {
|
||||
switch (data.provider_type) {
|
||||
case CollectionType.custom:
|
||||
return storeCustomTools ?? customTools
|
||||
case CollectionType.mcp:
|
||||
return storeMcpTools ?? mcpTools
|
||||
case CollectionType.workflow:
|
||||
return storeWorkflowTools ?? workflowTools
|
||||
case CollectionType.builtIn:
|
||||
default:
|
||||
return storeBuiltInTools ?? buildInTools
|
||||
}
|
||||
})()
|
||||
|
||||
const collectionsToSearch = [
|
||||
primaryCollection,
|
||||
storeBuiltInTools ?? buildInTools,
|
||||
storeCustomTools ?? customTools,
|
||||
storeWorkflowTools ?? workflowTools,
|
||||
storeMcpTools ?? mcpTools,
|
||||
] as Array<ToolWithProvider[] | undefined>
|
||||
|
||||
const seen = new Set<ToolWithProvider[]>()
|
||||
for (const collection of collectionsToSearch) {
|
||||
if (!collection || seen.has(collection))
|
||||
continue
|
||||
seen.add(collection)
|
||||
const matched = collection.find((toolWithProvider) => {
|
||||
if (canFindTool(toolWithProvider.id, data.provider_id))
|
||||
return true
|
||||
if (data.plugin_id && toolWithProvider.plugin_id === data.plugin_id)
|
||||
return true
|
||||
return data.provider_name === toolWithProvider.name
|
||||
})
|
||||
if (matched?.icon)
|
||||
return matched.icon
|
||||
}
|
||||
|
||||
if (data.provider_icon)
|
||||
return data.provider_icon
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (isDataSourceNode(data))
|
||||
return dataSourceList?.find(toolWithProvider => toolWithProvider.plugin_id === data.plugin_id)?.icon
|
||||
|
||||
return undefined
|
||||
}, [workflowStore, triggerPlugins, buildInTools, customTools, workflowTools, mcpTools])
|
||||
|
||||
return getToolIcon
|
||||
}
|
||||
154
dify/web/app/components/workflow/hooks/use-workflow-history.ts
Normal file
154
dify/web/app/components/workflow/hooks/use-workflow-history.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
useCallback,
|
||||
useRef, useState,
|
||||
} from 'react'
|
||||
import { debounce } from 'lodash-es'
|
||||
import {
|
||||
useStoreApi,
|
||||
} from 'reactflow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useWorkflowHistoryStore } from '../workflow-history-store'
|
||||
import type { WorkflowHistoryEventMeta } from '../workflow-history-store'
|
||||
|
||||
/**
|
||||
* All supported Events that create a new history state.
|
||||
* Current limitations:
|
||||
* - InputChange events in Node Panels do not trigger state changes.
|
||||
* - Resizing UI elements does not trigger state changes.
|
||||
*/
|
||||
export const WorkflowHistoryEvent = {
|
||||
NodeTitleChange: 'NodeTitleChange',
|
||||
NodeDescriptionChange: 'NodeDescriptionChange',
|
||||
NodeDragStop: 'NodeDragStop',
|
||||
NodeChange: 'NodeChange',
|
||||
NodeConnect: 'NodeConnect',
|
||||
NodePaste: 'NodePaste',
|
||||
NodeDelete: 'NodeDelete',
|
||||
EdgeDelete: 'EdgeDelete',
|
||||
EdgeDeleteByDeleteBranch: 'EdgeDeleteByDeleteBranch',
|
||||
NodeAdd: 'NodeAdd',
|
||||
NodeResize: 'NodeResize',
|
||||
NoteAdd: 'NoteAdd',
|
||||
NoteChange: 'NoteChange',
|
||||
NoteDelete: 'NoteDelete',
|
||||
LayoutOrganize: 'LayoutOrganize',
|
||||
} as const
|
||||
|
||||
export type WorkflowHistoryEventT = keyof typeof WorkflowHistoryEvent
|
||||
|
||||
export const useWorkflowHistory = () => {
|
||||
const store = useStoreApi()
|
||||
const { store: workflowHistoryStore } = useWorkflowHistoryStore()
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [undoCallbacks, setUndoCallbacks] = useState<(() => void)[]>([])
|
||||
const [redoCallbacks, setRedoCallbacks] = useState<(() => void)[]>([])
|
||||
|
||||
const onUndo = useCallback((callback: () => void) => {
|
||||
setUndoCallbacks(prev => [...prev, callback])
|
||||
return () => setUndoCallbacks(prev => prev.filter(cb => cb !== callback))
|
||||
}, [])
|
||||
|
||||
const onRedo = useCallback((callback: () => void) => {
|
||||
setRedoCallbacks(prev => [...prev, callback])
|
||||
return () => setRedoCallbacks(prev => prev.filter(cb => cb !== callback))
|
||||
}, [])
|
||||
|
||||
const undo = useCallback(() => {
|
||||
workflowHistoryStore.temporal.getState().undo()
|
||||
undoCallbacks.forEach(callback => callback())
|
||||
}, [undoCallbacks, workflowHistoryStore.temporal])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
workflowHistoryStore.temporal.getState().redo()
|
||||
redoCallbacks.forEach(callback => callback())
|
||||
}, [redoCallbacks, workflowHistoryStore.temporal])
|
||||
|
||||
// Some events may be triggered multiple times in a short period of time.
|
||||
// We debounce the history state update to avoid creating multiple history states
|
||||
// with minimal changes.
|
||||
const saveStateToHistoryRef = useRef(debounce((event: WorkflowHistoryEventT, meta?: WorkflowHistoryEventMeta) => {
|
||||
workflowHistoryStore.setState({
|
||||
workflowHistoryEvent: event,
|
||||
workflowHistoryEventMeta: meta,
|
||||
nodes: store.getState().getNodes(),
|
||||
edges: store.getState().edges,
|
||||
})
|
||||
}, 500))
|
||||
|
||||
const saveStateToHistory = useCallback((event: WorkflowHistoryEventT, meta?: WorkflowHistoryEventMeta) => {
|
||||
switch (event) {
|
||||
case WorkflowHistoryEvent.NoteChange:
|
||||
// Hint: Note change does not trigger when note text changes,
|
||||
// because the note editors have their own history states.
|
||||
saveStateToHistoryRef.current(event, meta)
|
||||
break
|
||||
case WorkflowHistoryEvent.NodeTitleChange:
|
||||
case WorkflowHistoryEvent.NodeDescriptionChange:
|
||||
case WorkflowHistoryEvent.NodeDragStop:
|
||||
case WorkflowHistoryEvent.NodeChange:
|
||||
case WorkflowHistoryEvent.NodeConnect:
|
||||
case WorkflowHistoryEvent.NodePaste:
|
||||
case WorkflowHistoryEvent.NodeDelete:
|
||||
case WorkflowHistoryEvent.EdgeDelete:
|
||||
case WorkflowHistoryEvent.EdgeDeleteByDeleteBranch:
|
||||
case WorkflowHistoryEvent.NodeAdd:
|
||||
case WorkflowHistoryEvent.NodeResize:
|
||||
case WorkflowHistoryEvent.NoteAdd:
|
||||
case WorkflowHistoryEvent.LayoutOrganize:
|
||||
case WorkflowHistoryEvent.NoteDelete:
|
||||
saveStateToHistoryRef.current(event, meta)
|
||||
break
|
||||
default:
|
||||
// We do not create a history state for every event.
|
||||
// Some events of reactflow may change things the user would not want to undo/redo.
|
||||
// For example: UI state changes like selecting a node.
|
||||
break
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getHistoryLabel = useCallback((event: WorkflowHistoryEventT) => {
|
||||
switch (event) {
|
||||
case WorkflowHistoryEvent.NodeTitleChange:
|
||||
return t('workflow.changeHistory.nodeTitleChange')
|
||||
case WorkflowHistoryEvent.NodeDescriptionChange:
|
||||
return t('workflow.changeHistory.nodeDescriptionChange')
|
||||
case WorkflowHistoryEvent.LayoutOrganize:
|
||||
case WorkflowHistoryEvent.NodeDragStop:
|
||||
return t('workflow.changeHistory.nodeDragStop')
|
||||
case WorkflowHistoryEvent.NodeChange:
|
||||
return t('workflow.changeHistory.nodeChange')
|
||||
case WorkflowHistoryEvent.NodeConnect:
|
||||
return t('workflow.changeHistory.nodeConnect')
|
||||
case WorkflowHistoryEvent.NodePaste:
|
||||
return t('workflow.changeHistory.nodePaste')
|
||||
case WorkflowHistoryEvent.NodeDelete:
|
||||
return t('workflow.changeHistory.nodeDelete')
|
||||
case WorkflowHistoryEvent.NodeAdd:
|
||||
return t('workflow.changeHistory.nodeAdd')
|
||||
case WorkflowHistoryEvent.EdgeDelete:
|
||||
case WorkflowHistoryEvent.EdgeDeleteByDeleteBranch:
|
||||
return t('workflow.changeHistory.edgeDelete')
|
||||
case WorkflowHistoryEvent.NodeResize:
|
||||
return t('workflow.changeHistory.nodeResize')
|
||||
case WorkflowHistoryEvent.NoteAdd:
|
||||
return t('workflow.changeHistory.noteAdd')
|
||||
case WorkflowHistoryEvent.NoteChange:
|
||||
return t('workflow.changeHistory.noteChange')
|
||||
case WorkflowHistoryEvent.NoteDelete:
|
||||
return t('workflow.changeHistory.noteDelete')
|
||||
default:
|
||||
return 'Unknown Event'
|
||||
}
|
||||
}, [t])
|
||||
|
||||
return {
|
||||
store: workflowHistoryStore,
|
||||
saveStateToHistory,
|
||||
getHistoryLabel,
|
||||
undo,
|
||||
redo,
|
||||
onUndo,
|
||||
onRedo,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import {
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import { useReactFlow, useStoreApi } from 'reactflow'
|
||||
import { produce } from 'immer'
|
||||
import { useStore, useWorkflowStore } from '../store'
|
||||
import {
|
||||
CUSTOM_NODE,
|
||||
NODE_LAYOUT_HORIZONTAL_PADDING,
|
||||
NODE_LAYOUT_VERTICAL_PADDING,
|
||||
WORKFLOW_DATA_UPDATE,
|
||||
} from '../constants'
|
||||
import type { WorkflowDataUpdater } from '../types'
|
||||
import { BlockEnum, ControlMode } from '../types'
|
||||
import {
|
||||
getLayoutByDagre,
|
||||
getLayoutForChildNodes,
|
||||
initialEdges,
|
||||
initialNodes,
|
||||
} from '../utils'
|
||||
import type { LayoutResult } from '../utils'
|
||||
import {
|
||||
useNodesReadOnly,
|
||||
useSelectionInteractions,
|
||||
useWorkflowReadOnly,
|
||||
} from '../hooks'
|
||||
import { useEdgesInteractionsWithoutSync } from './use-edges-interactions-without-sync'
|
||||
import { useNodesInteractionsWithoutSync } from './use-nodes-interactions-without-sync'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
import { WorkflowHistoryEvent, useWorkflowHistory } from './use-workflow-history'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
|
||||
export const useWorkflowInteractions = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { handleNodeCancelRunningStatus } = useNodesInteractionsWithoutSync()
|
||||
const { handleEdgeCancelRunningStatus } = useEdgesInteractionsWithoutSync()
|
||||
|
||||
const handleCancelDebugAndPreviewPanel = useCallback(() => {
|
||||
workflowStore.setState({
|
||||
showDebugAndPreviewPanel: false,
|
||||
workflowRunningData: undefined,
|
||||
})
|
||||
handleNodeCancelRunningStatus()
|
||||
handleEdgeCancelRunningStatus()
|
||||
}, [workflowStore, handleNodeCancelRunningStatus, handleEdgeCancelRunningStatus])
|
||||
|
||||
return {
|
||||
handleCancelDebugAndPreviewPanel,
|
||||
}
|
||||
}
|
||||
|
||||
export const useWorkflowMoveMode = () => {
|
||||
const setControlMode = useStore(s => s.setControlMode)
|
||||
const {
|
||||
getNodesReadOnly,
|
||||
} = useNodesReadOnly()
|
||||
const { handleSelectionCancel } = useSelectionInteractions()
|
||||
|
||||
const handleModePointer = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
setControlMode(ControlMode.Pointer)
|
||||
}, [getNodesReadOnly, setControlMode])
|
||||
|
||||
const handleModeHand = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
setControlMode(ControlMode.Hand)
|
||||
handleSelectionCancel()
|
||||
}, [getNodesReadOnly, setControlMode, handleSelectionCancel])
|
||||
|
||||
return {
|
||||
handleModePointer,
|
||||
handleModeHand,
|
||||
}
|
||||
}
|
||||
|
||||
export const useWorkflowOrganize = () => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const store = useStoreApi()
|
||||
const reactflow = useReactFlow()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const { saveStateToHistory } = useWorkflowHistory()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
|
||||
const handleLayout = useCallback(async () => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
workflowStore.setState({ nodeAnimation: true })
|
||||
const {
|
||||
getNodes,
|
||||
edges,
|
||||
setNodes,
|
||||
} = store.getState()
|
||||
const { setViewport } = reactflow
|
||||
const nodes = getNodes()
|
||||
|
||||
const loopAndIterationNodes = nodes.filter(
|
||||
node => (node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration)
|
||||
&& !node.parentId
|
||||
&& node.type === CUSTOM_NODE,
|
||||
)
|
||||
|
||||
const childLayoutEntries = await Promise.all(
|
||||
loopAndIterationNodes.map(async node => [
|
||||
node.id,
|
||||
await getLayoutForChildNodes(node.id, nodes, edges),
|
||||
] as const),
|
||||
)
|
||||
const childLayoutsMap = childLayoutEntries.reduce((acc, [nodeId, layout]) => {
|
||||
if (layout)
|
||||
acc[nodeId] = layout
|
||||
return acc
|
||||
}, {} as Record<string, LayoutResult>)
|
||||
|
||||
const containerSizeChanges: Record<string, { width: number, height: number }> = {}
|
||||
|
||||
loopAndIterationNodes.forEach((parentNode) => {
|
||||
const childLayout = childLayoutsMap[parentNode.id]
|
||||
if (!childLayout) return
|
||||
|
||||
const {
|
||||
bounds,
|
||||
nodes: layoutNodes,
|
||||
} = childLayout
|
||||
|
||||
if (!layoutNodes.size)
|
||||
return
|
||||
|
||||
const requiredWidth = (bounds.maxX - bounds.minX) + NODE_LAYOUT_HORIZONTAL_PADDING * 2
|
||||
const requiredHeight = (bounds.maxY - bounds.minY) + NODE_LAYOUT_VERTICAL_PADDING * 2
|
||||
|
||||
containerSizeChanges[parentNode.id] = {
|
||||
width: Math.max(parentNode.width || 0, requiredWidth),
|
||||
height: Math.max(parentNode.height || 0, requiredHeight),
|
||||
}
|
||||
})
|
||||
|
||||
const nodesWithUpdatedSizes = produce(nodes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
if ((node.data.type === BlockEnum.Loop || node.data.type === BlockEnum.Iteration)
|
||||
&& containerSizeChanges[node.id]) {
|
||||
node.width = containerSizeChanges[node.id].width
|
||||
node.height = containerSizeChanges[node.id].height
|
||||
|
||||
if (node.data.type === BlockEnum.Loop) {
|
||||
node.data.width = containerSizeChanges[node.id].width
|
||||
node.data.height = containerSizeChanges[node.id].height
|
||||
}
|
||||
else if (node.data.type === BlockEnum.Iteration) {
|
||||
node.data.width = containerSizeChanges[node.id].width
|
||||
node.data.height = containerSizeChanges[node.id].height
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const layout = await getLayoutByDagre(nodesWithUpdatedSizes, edges)
|
||||
|
||||
// Build layer map for vertical alignment - nodes in the same layer should align
|
||||
const layerMap = new Map<number, { minY: number; maxHeight: number }>()
|
||||
layout.nodes.forEach((layoutInfo) => {
|
||||
if (layoutInfo.layer !== undefined) {
|
||||
const existing = layerMap.get(layoutInfo.layer)
|
||||
const newLayerInfo = {
|
||||
minY: existing ? Math.min(existing.minY, layoutInfo.y) : layoutInfo.y,
|
||||
maxHeight: existing ? Math.max(existing.maxHeight, layoutInfo.height) : layoutInfo.height,
|
||||
}
|
||||
layerMap.set(layoutInfo.layer, newLayerInfo)
|
||||
}
|
||||
})
|
||||
|
||||
const newNodes = produce(nodesWithUpdatedSizes, (draft) => {
|
||||
draft.forEach((node) => {
|
||||
if (!node.parentId && node.type === CUSTOM_NODE) {
|
||||
const layoutInfo = layout.nodes.get(node.id)
|
||||
if (!layoutInfo)
|
||||
return
|
||||
|
||||
// Calculate vertical position with layer alignment
|
||||
let yPosition = layoutInfo.y
|
||||
if (layoutInfo.layer !== undefined) {
|
||||
const layerInfo = layerMap.get(layoutInfo.layer)
|
||||
if (layerInfo) {
|
||||
// Align to the center of the tallest node in this layer
|
||||
const layerCenterY = layerInfo.minY + layerInfo.maxHeight / 2
|
||||
yPosition = layerCenterY - layoutInfo.height / 2
|
||||
}
|
||||
}
|
||||
|
||||
node.position = {
|
||||
x: layoutInfo.x,
|
||||
y: yPosition,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
loopAndIterationNodes.forEach((parentNode) => {
|
||||
const childLayout = childLayoutsMap[parentNode.id]
|
||||
if (!childLayout)
|
||||
return
|
||||
|
||||
const childNodes = draft.filter(node => node.parentId === parentNode.id)
|
||||
const {
|
||||
bounds,
|
||||
nodes: layoutNodes,
|
||||
} = childLayout
|
||||
|
||||
childNodes.forEach((childNode) => {
|
||||
const layoutInfo = layoutNodes.get(childNode.id)
|
||||
if (!layoutInfo)
|
||||
return
|
||||
|
||||
childNode.position = {
|
||||
x: NODE_LAYOUT_HORIZONTAL_PADDING + (layoutInfo.x - bounds.minX),
|
||||
y: NODE_LAYOUT_VERTICAL_PADDING + (layoutInfo.y - bounds.minY),
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
setNodes(newNodes)
|
||||
const zoom = 0.7
|
||||
setViewport({
|
||||
x: 0,
|
||||
y: 0,
|
||||
zoom,
|
||||
})
|
||||
saveStateToHistory(WorkflowHistoryEvent.LayoutOrganize)
|
||||
setTimeout(() => {
|
||||
handleSyncWorkflowDraft()
|
||||
})
|
||||
}, [getNodesReadOnly, store, reactflow, workflowStore, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
return {
|
||||
handleLayout,
|
||||
}
|
||||
}
|
||||
|
||||
export const useWorkflowZoom = () => {
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const { getWorkflowReadOnly } = useWorkflowReadOnly()
|
||||
const {
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomTo,
|
||||
fitView,
|
||||
} = useReactFlow()
|
||||
|
||||
const handleFitView = useCallback(() => {
|
||||
if (getWorkflowReadOnly())
|
||||
return
|
||||
|
||||
fitView()
|
||||
handleSyncWorkflowDraft()
|
||||
}, [getWorkflowReadOnly, fitView, handleSyncWorkflowDraft])
|
||||
|
||||
const handleBackToOriginalSize = useCallback(() => {
|
||||
if (getWorkflowReadOnly())
|
||||
return
|
||||
|
||||
zoomTo(1)
|
||||
handleSyncWorkflowDraft()
|
||||
}, [getWorkflowReadOnly, zoomTo, handleSyncWorkflowDraft])
|
||||
|
||||
const handleSizeToHalf = useCallback(() => {
|
||||
if (getWorkflowReadOnly())
|
||||
return
|
||||
|
||||
zoomTo(0.5)
|
||||
handleSyncWorkflowDraft()
|
||||
}, [getWorkflowReadOnly, zoomTo, handleSyncWorkflowDraft])
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
if (getWorkflowReadOnly())
|
||||
return
|
||||
|
||||
zoomOut()
|
||||
handleSyncWorkflowDraft()
|
||||
}, [getWorkflowReadOnly, zoomOut, handleSyncWorkflowDraft])
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
if (getWorkflowReadOnly())
|
||||
return
|
||||
|
||||
zoomIn()
|
||||
handleSyncWorkflowDraft()
|
||||
}, [getWorkflowReadOnly, zoomIn, handleSyncWorkflowDraft])
|
||||
|
||||
return {
|
||||
handleFitView,
|
||||
handleBackToOriginalSize,
|
||||
handleSizeToHalf,
|
||||
handleZoomOut,
|
||||
handleZoomIn,
|
||||
}
|
||||
}
|
||||
|
||||
export const useWorkflowUpdate = () => {
|
||||
const reactflow = useReactFlow()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
const handleUpdateWorkflowCanvas = useCallback((payload: WorkflowDataUpdater) => {
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
} = payload
|
||||
const { setViewport } = reactflow
|
||||
eventEmitter?.emit({
|
||||
type: WORKFLOW_DATA_UPDATE,
|
||||
payload: {
|
||||
nodes: initialNodes(nodes, edges),
|
||||
edges: initialEdges(edges, nodes),
|
||||
},
|
||||
} as any)
|
||||
|
||||
// Only set viewport if it exists and is valid
|
||||
if (viewport && typeof viewport.x === 'number' && typeof viewport.y === 'number' && typeof viewport.zoom === 'number')
|
||||
setViewport(viewport)
|
||||
}, [eventEmitter, reactflow])
|
||||
|
||||
return {
|
||||
handleUpdateWorkflowCanvas,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user