feat: enhance model plugin workflow checks and model provider management UX (#33289)

Signed-off-by: yyh <yuanyouhuilyz@gmail.com>
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Coding On Star <447357187@qq.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
Co-authored-by: statxc <tyleradams93226@gmail.com>
This commit is contained in:
yyh
2026-03-18 10:16:15 +08:00
committed by GitHub
parent aa4a9877f5
commit bbe975c6bc
319 changed files with 19582 additions and 5541 deletions

View File

@ -65,6 +65,7 @@ import type { Shape as HooksStoreShape } from '../hooks-store/store'
import type { Shape } from '../store/workflow'
import type { Edge, Node, WorkflowRunningData } from '../types'
import type { WorkflowHistoryStoreApi } from '../workflow-history-store'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, renderHook } from '@testing-library/react'
import isDeepEqual from 'fast-deep-equal'
import * as React from 'react'
@ -154,6 +155,13 @@ function createWorkflowWrapper(
const historyCtxValue = historyConfig
? createTestHistoryStoreContext(historyConfig)
: undefined
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
return ({ children }: { children: React.ReactNode }) => {
let inner: React.ReactNode = children
@ -175,9 +183,13 @@ function createWorkflowWrapper(
}
return React.createElement(
WorkflowContext.Provider,
{ value: stores.store },
inner,
QueryClientProvider,
{ client: queryClient },
React.createElement(
WorkflowContext.Provider,
{ value: stores.store },
inner,
),
)
}
}

View File

@ -27,6 +27,7 @@ import {
VariableX,
WebhookLine,
} from '@/app/components/base/icons/src/vender/workflow'
import { API_PREFIX } from '@/config'
import { cn } from '@/utils/classnames'
import { BlockEnum } from './types'
@ -82,6 +83,17 @@ const getIcon = (type: BlockEnum, className: string) => {
return <DefaultIcon className={className} />
}
const normalizeToolIconUrl = (toolIcon: string) => {
const protectedPluginIconPath = '/workspaces/current/plugin/icon'
const pathIndex = toolIcon.indexOf(protectedPluginIconPath)
if (pathIndex < 0)
return toolIcon
return `${API_PREFIX}${toolIcon.slice(pathIndex)}`
}
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',
@ -119,6 +131,9 @@ const BlockIcon: FC<BlockIconProps> = ({
}) => {
const isToolOrDataSourceOrTriggerPlugin = type === BlockEnum.Tool || type === BlockEnum.DataSource || type === BlockEnum.TriggerPlugin
const showDefaultIcon = !isToolOrDataSourceOrTriggerPlugin || !toolIcon
const resolvedToolIcon = typeof toolIcon === 'string'
? normalizeToolIconUrl(toolIcon)
: toolIcon
return (
<div className={
@ -142,12 +157,12 @@ const BlockIcon: FC<BlockIconProps> = ({
!showDefaultIcon && (
<>
{
typeof toolIcon === 'string'
typeof resolvedToolIcon === 'string'
? (
<div
className="h-full w-full shrink-0 rounded-md bg-cover bg-center"
style={{
backgroundImage: `url(${toolIcon})`,
backgroundImage: `url(${resolvedToolIcon})`,
}}
>
</div>
@ -156,8 +171,8 @@ const BlockIcon: FC<BlockIconProps> = ({
<AppIcon
className="!h-full !w-full shrink-0"
size="tiny"
icon={toolIcon?.content}
background={toolIcon?.background}
icon={resolvedToolIcon?.content}
background={resolvedToolIcon?.background}
/>
)
}

View File

@ -1,201 +0,0 @@
import type { ChecklistItem } from '../hooks/use-checklist'
import type {
BlockEnum,
CommonEdgeType,
} from '../types'
import {
RiCloseLine,
RiListCheck3,
} from '@remixicon/react'
import {
memo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
useEdges,
} from 'reactflow'
import { Warning } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import { IconR } from '@/app/components/base/icons/src/vender/line/arrows'
import {
ChecklistSquare,
} from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { cn } from '@/utils/classnames'
import BlockIcon from '../block-icon'
import {
useChecklist,
useNodesInteractions,
} from '../hooks'
type WorkflowChecklistProps = {
disabled: boolean
showGoTo?: boolean
onItemClick?: (item: ChecklistItem) => void
}
const WorkflowChecklist = ({
disabled,
showGoTo = true,
onItemClick,
}: 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) => {
const goToEnabled = showGoTo && item.canNavigate && !item.disableGoTo
if (!goToEnabled)
return
if (onItemClick)
onItemClick(item)
else
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('panel.checklist', { ns: 'workflow' })}
{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('panel.checklistTip', { ns: 'workflow' })}</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',
showGoTo && node.canNavigate && !node.disableGoTo ? '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>
{
(showGoTo && node.canNavigate && !node.disableGoTo) && (
<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('panel.goTo', { ns: 'workflow' })}
</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('common.needConnectTip', { ns: 'workflow' })}
</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('panel.checklistResolved', { ns: 'workflow' })}
</div>
)
}
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default memo(WorkflowChecklist)

View File

@ -0,0 +1,131 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '../../types'
import WorkflowChecklist from './index'
let mockChecklistItems = [
{
id: 'plugin-1',
type: BlockEnum.Tool,
title: 'Missing Plugin',
errorMessages: [],
canNavigate: false,
isPluginMissing: true,
},
{
id: 'node-1',
type: BlockEnum.LLM,
title: 'Broken Node',
errorMessages: ['Needs configuration'],
canNavigate: true,
isPluginMissing: false,
},
]
const mockHandleNodeSelect = vi.fn()
type PopoverProps = {
children: ReactNode
open?: boolean
onOpenChange?: (open: boolean) => void
}
let latestOnOpenChange: PopoverProps['onOpenChange']
vi.mock('reactflow', () => ({
useEdges: () => [],
}))
vi.mock('@/app/components/workflow/store/workflow/use-nodes', () => ({
default: () => [],
}))
vi.mock('../../hooks', () => ({
useChecklist: () => mockChecklistItems,
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}))
vi.mock('@/app/components/base/ui/popover', () => ({
Popover: ({ children, onOpenChange }: PopoverProps) => {
latestOnOpenChange = onOpenChange
return <div data-testid="popover">{children}</div>
},
PopoverTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
PopoverContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
PopoverClose: ({ children, className }: { children: ReactNode, className?: string }) => <button className={className}>{children}</button>,
}))
vi.mock('./plugin-group', () => ({
ChecklistPluginGroup: ({ items }: { items: Array<{ title: string }> }) => <div data-testid="plugin-group">{items.map(item => item.title).join(',')}</div>,
}))
vi.mock('./node-group', () => ({
ChecklistNodeGroup: ({ item, onItemClick }: { item: { title: string }, onItemClick: (item: { title: string }) => void }) => (
<button data-testid={`node-group-${item.title}`} onClick={() => onItemClick(item)}>
{item.title}
</button>
),
}))
describe('WorkflowChecklist', () => {
beforeEach(() => {
vi.clearAllMocks()
latestOnOpenChange = undefined
mockChecklistItems = [
{
id: 'plugin-1',
type: BlockEnum.Tool,
title: 'Missing Plugin',
errorMessages: [],
canNavigate: false,
isPluginMissing: true,
},
{
id: 'node-1',
type: BlockEnum.LLM,
title: 'Broken Node',
errorMessages: ['Needs configuration'],
canNavigate: true,
isPluginMissing: false,
},
]
})
it('should split checklist items into plugin and node groups and delegate clicks to node selection by default', () => {
render(<WorkflowChecklist disabled={false} />)
expect(screen.getByText('2')).toBeInTheDocument()
expect(screen.getByTestId('plugin-group')).toHaveTextContent('Missing Plugin')
fireEvent.click(screen.getByTestId('node-group-Broken Node'))
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
})
it('should use the custom item click handler when provided', () => {
const onItemClick = vi.fn()
render(<WorkflowChecklist disabled={false} onItemClick={onItemClick} />)
fireEvent.click(screen.getByTestId('node-group-Broken Node'))
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' }))
expect(mockHandleNodeSelect).not.toHaveBeenCalled()
})
it('should render the resolved state when there are no checklist warnings', () => {
mockChecklistItems = []
render(<WorkflowChecklist disabled={false} />)
expect(screen.getByText(/checklistResolved/i)).toBeInTheDocument()
})
it('should ignore popover open changes when the checklist is disabled', () => {
render(<WorkflowChecklist disabled={true} />)
latestOnOpenChange?.(true)
expect(screen.getByText('2').closest('button')).toBeDisabled()
})
})

View File

@ -0,0 +1,151 @@
import type { ChecklistItem } from '../../hooks/use-checklist'
import type {
CommonEdgeType,
} from '../../types'
import {
memo,
useMemo,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
useEdges,
} from 'reactflow'
import {
Popover,
PopoverClose,
PopoverContent,
PopoverTrigger,
} from '@/app/components/base/ui/popover'
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { cn } from '@/utils/classnames'
import {
useChecklist,
useNodesInteractions,
} from '../../hooks'
import { ChecklistNodeGroup } from './node-group'
import { ChecklistPluginGroup } from './plugin-group'
type WorkflowChecklistProps = {
disabled: boolean
showGoTo?: boolean
onItemClick?: (item: ChecklistItem) => void
}
const WorkflowChecklist = ({
disabled,
showGoTo = true,
onItemClick,
}: 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 { pluginItems, nodeItems } = useMemo(() => {
const plugins: ChecklistItem[] = []
const regular: ChecklistItem[] = []
for (const item of needWarningNodes) {
if (item.isPluginMissing)
plugins.push(item)
else
regular.push(item)
}
return { pluginItems: plugins, nodeItems: regular }
}, [needWarningNodes])
const handleItemClick = (item: ChecklistItem) => {
if (onItemClick)
onItemClick(item)
else
handleNodeSelect(item.id)
setOpen(false)
}
return (
<Popover open={open} onOpenChange={newOpen => !disabled && setOpen(newOpen)}>
<PopoverTrigger
render={(
<button
type="button"
className={cn(
'relative ml-0.5 flex h-7 w-7 items-center justify-center rounded-md border-none bg-transparent p-0',
disabled && 'cursor-not-allowed opacity-50',
)}
disabled={disabled || undefined}
>
<span
className={cn('group flex h-full w-full items-center justify-center rounded-md hover:bg-state-accent-hover', open && 'bg-state-accent-hover')}
>
<span
className={cn('i-ri-list-check-3 h-4 w-4 group-hover:text-components-button-secondary-accent-text', open ? 'text-components-button-secondary-accent-text' : 'text-components-button-ghost-text')}
/>
</span>
{!!needWarningNodes.length && (
<span 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-text-warning-secondary text-[11px] font-semibold text-white">
{needWarningNodes.length}
</span>
)}
</button>
)}
/>
<PopoverContent
placement="bottom-start"
sideOffset={12}
alignOffset={-30}
popupClassName="w-[420px] rounded-2xl bg-background-default-subtle"
>
<div
className="overflow-y-auto"
style={{ maxHeight: 'calc(2 / 3 * 100vh)' }}
>
<div className="flex flex-col gap-0.5 px-3 pb-1 pt-3.5">
<div className="flex items-start px-1">
<div className="min-w-0 grow pr-8">
<h2 className="text-base font-semibold leading-6 text-text-primary">
{t('panel.checklist', { ns: 'workflow' })}
{needWarningNodes.length > 0 && `(${needWarningNodes.length})`}
</h2>
</div>
<PopoverClose className="-mr-0.5 -mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-lg">
<span className="i-ri-close-line size-4 text-text-tertiary" />
</PopoverClose>
</div>
{needWarningNodes.length > 0 && (
<p className="px-1 text-xs leading-4 text-text-tertiary">
{t('panel.checklistDescription', { ns: 'workflow' })}
</p>
)}
</div>
{needWarningNodes.length > 0
? (
<div className="flex flex-col gap-1 px-4 pb-4 pt-1">
{pluginItems.length > 0 && (
<ChecklistPluginGroup items={pluginItems} />
)}
{nodeItems.map(item => (
<ChecklistNodeGroup
key={item.id}
item={item}
showGoTo={showGoTo}
onItemClick={handleItemClick}
/>
))}
</div>
)
: (
<div className="mx-4 mb-3 rounded-lg py-4 text-center text-xs text-text-tertiary">
<span className="i-custom-vender-line-general-checklist-square mx-auto mb-[5px] block h-8 w-8 text-text-quaternary" />
{t('panel.checklistResolved', { ns: 'workflow' })}
</div>
)}
</div>
</PopoverContent>
</Popover>
)
}
export default memo(WorkflowChecklist)

View File

@ -0,0 +1,21 @@
import { cn } from '@/utils/classnames'
type ItemIndicatorProps = {
className?: string
}
export const ItemIndicator = ({ className }: ItemIndicatorProps) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="24"
viewBox="0 0 20 24"
fill="none"
className={cn('shrink-0', className)}
>
<path d="M9.5 0H10.5V24H9.5V0Z" fill="currentColor" className="text-divider-regular" />
<circle cx="10" cy="12" r="3.25" fill="#F79009" stroke="white" strokeWidth="1.5" />
</svg>
)
}

View File

@ -0,0 +1,61 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum } from '../../types'
import { ChecklistNodeGroup } from './node-group'
vi.mock('../../block-icon', () => ({
default: () => <div data-testid="block-icon" />,
}))
vi.mock('./item-indicator', () => ({
ItemIndicator: () => <div data-testid="item-indicator" />,
}))
const createItem = (overrides: Record<string, unknown> = {}) => ({
id: 'node-1',
type: BlockEnum.LLM,
title: 'Broken Node',
errorMessages: ['Needs configuration'],
canNavigate: true,
disableGoTo: false,
unConnected: false,
...overrides,
})
describe('ChecklistNodeGroup', () => {
it('should render errors and the connection warning, and allow navigation when go-to is enabled', () => {
const onItemClick = vi.fn()
render(
<ChecklistNodeGroup
item={createItem({ unConnected: true }) as never}
showGoTo={true}
onItemClick={onItemClick}
/>,
)
expect(screen.getByText('Needs configuration')).toBeInTheDocument()
expect(screen.getByText(/needConnectTip/i)).toBeInTheDocument()
expect(screen.getAllByText(/goToFix/i)).toHaveLength(2)
fireEvent.click(screen.getByText('Needs configuration'))
expect(onItemClick).toHaveBeenCalledWith(expect.objectContaining({ id: 'node-1' }))
})
it('should not allow navigation when go-to is disabled', () => {
const onItemClick = vi.fn()
render(
<ChecklistNodeGroup
item={createItem({ disableGoTo: true }) as never}
showGoTo={true}
onItemClick={onItemClick}
/>,
)
fireEvent.click(screen.getByText('Needs configuration'))
expect(onItemClick).not.toHaveBeenCalled()
expect(screen.queryByText(/goToFix/i)).not.toBeInTheDocument()
})
})

View File

@ -0,0 +1,75 @@
import type { ChecklistItem } from '../../hooks/use-checklist'
import type { BlockEnum } from '../../types'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import BlockIcon from '../../block-icon'
import { ItemIndicator } from './item-indicator'
type ChecklistSubItem = {
key: string
message: string
}
export const ChecklistNodeGroup = memo(({
item,
showGoTo,
onItemClick,
}: {
item: ChecklistItem
showGoTo: boolean
onItemClick: (item: ChecklistItem) => void
}) => {
const { t } = useTranslation()
const goToEnabled = showGoTo && item.canNavigate && !item.disableGoTo
const subItems = useMemo(() => {
const items: ChecklistSubItem[] = []
for (let i = 0; i < item.errorMessages.length; i++)
items.push({ key: `error-${i}`, message: item.errorMessages[i] })
if (item.unConnected)
items.push({ key: 'unconnected', message: t('common.needConnectTip', { ns: 'workflow' }) })
return items
}, [item.errorMessages, item.unConnected, t])
return (
<div className="overflow-clip rounded-[10px] bg-components-panel-on-panel-item-bg">
<div className="flex items-center gap-2 px-2 pt-2">
<BlockIcon
type={item.type as BlockEnum}
size="sm"
toolIcon={item.toolIcon}
/>
<span className="min-w-0 grow truncate text-sm font-medium leading-5 text-text-primary">
{item.title}
</span>
</div>
<div className="p-1">
{subItems.map(sub => (
<div
key={sub.key}
className={cn(
'group/item flex items-center gap-2 rounded-lg px-1',
goToEnabled && 'cursor-pointer hover:bg-state-base-hover',
)}
onClick={() => goToEnabled && onItemClick(item)}
>
<ItemIndicator />
<span className="min-w-0 grow truncate text-xs leading-4 text-text-warning">
{sub.message}
</span>
{goToEnabled && (
<div className="flex shrink-0 items-center gap-0.5 pr-0.5 opacity-0 transition-opacity duration-150 group-hover/item:opacity-100">
<span className="whitespace-nowrap text-xs font-medium leading-4 text-text-accent">
{t('panel.goToFix', { ns: 'workflow' })}
</span>
<span className="i-ri-arrow-right-line size-3.5 text-text-accent" />
</div>
)}
</div>
))}
</div>
</div>
)
})
ChecklistNodeGroup.displayName = 'ChecklistNodeGroup'

View File

@ -0,0 +1,96 @@
import type { ChecklistItem } from '../../hooks/use-checklist'
import { fireEvent, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { Popover, PopoverContent } from '@/app/components/base/ui/popover'
import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store'
import { BlockEnum } from '../../types'
import { ChecklistPluginGroup } from './plugin-group'
const createChecklistItem = (overrides: Partial<ChecklistItem> = {}): ChecklistItem => ({
id: 'node-1',
type: BlockEnum.Tool,
title: 'Tool Node',
errorMessages: [],
canNavigate: false,
isPluginMissing: true,
pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256',
...overrides,
})
describe('ChecklistPluginGroup', () => {
const getInstallButton = () => {
return screen.getByText('workflow.nodes.agent.pluginInstaller.install').closest('button') as HTMLButtonElement
}
const renderInPopover = (items: ChecklistItem[]) => {
return render(
<Popover open>
<PopoverContent>
<ChecklistPluginGroup items={items} />
</PopoverContent>
</Popover>,
)
}
beforeEach(() => {
usePluginDependencyStore.setState({ dependencies: [] })
})
it('should set marketplace dependencies when install button is clicked', () => {
const items: ChecklistItem[] = [
createChecklistItem({ id: 'node-1', pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256' }),
createChecklistItem({ id: 'node-2', pluginUniqueIdentifier: 'langgenius/test-plugin:1.0.0@sha256' }),
createChecklistItem({ id: 'node-3', pluginUniqueIdentifier: 'langgenius/another-plugin:2.0.0@sha256' }),
]
renderInPopover(items)
fireEvent.click(getInstallButton())
expect(usePluginDependencyStore.getState().dependencies).toEqual([
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'langgenius/test-plugin:1.0.0@sha256',
plugin_unique_identifier: 'langgenius/test-plugin:1.0.0@sha256',
version: '1.0.0',
},
},
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'langgenius/another-plugin:2.0.0@sha256',
plugin_unique_identifier: 'langgenius/another-plugin:2.0.0@sha256',
version: '2.0.0',
},
},
])
})
it('should keep install button disabled when no identifier is available', () => {
renderInPopover([createChecklistItem({ pluginUniqueIdentifier: undefined })])
const installButton = getInstallButton()
expect(installButton).toBeDisabled()
fireEvent.click(installButton)
expect(usePluginDependencyStore.getState().dependencies).toEqual([])
})
it('should omit the version when the marketplace identifier does not include one', () => {
renderInPopover([createChecklistItem({ pluginUniqueIdentifier: 'langgenius/test-plugin@sha256' })])
fireEvent.click(getInstallButton())
expect(usePluginDependencyStore.getState().dependencies).toEqual([
{
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: 'langgenius/test-plugin@sha256',
plugin_unique_identifier: 'langgenius/test-plugin@sha256',
version: undefined,
},
},
])
})
})

View File

@ -0,0 +1,99 @@
import type { ChecklistItem } from '../../hooks/use-checklist'
import type { BlockEnum } from '../../types'
import type { Dependency } from '@/app/components/plugins/types'
import { memo, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { PopoverClose } from '@/app/components/base/ui/popover'
import BlockIcon from '../../block-icon'
import { useStore as usePluginDependencyStore } from '../../plugin-dependency/store'
import { ItemIndicator } from './item-indicator'
function getVersionFromMarketplaceIdentifier(identifier: string): string | undefined {
const withoutHash = identifier.split('@')[0]
const [, version] = withoutHash.split(':')
return version || undefined
}
export const ChecklistPluginGroup = memo(({
items,
}: {
items: ChecklistItem[]
}) => {
const { t } = useTranslation()
const identifiers = useMemo(
() => Array.from(
new Set(
items
.map(i => i.pluginUniqueIdentifier)
.filter((id): id is string => Boolean(id)),
),
),
[items],
)
const dependencies = useMemo<Dependency[]>(() => {
return identifiers.map((identifier) => {
return {
type: 'marketplace',
value: {
marketplace_plugin_unique_identifier: identifier,
plugin_unique_identifier: identifier,
version: getVersionFromMarketplaceIdentifier(identifier),
},
}
})
}, [identifiers])
const handleInstallAll = () => {
if (dependencies.length === 0)
return
const { setDependencies } = usePluginDependencyStore.getState()
setDependencies(dependencies)
}
return (
<div className="overflow-clip rounded-[10px] bg-components-panel-on-panel-item-bg">
<div className="flex items-center gap-2 px-2 pt-2">
<div className="flex size-5 shrink-0 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-icon-bg-midnight-solid shadow-xs">
<span className="i-ri-download-line size-3.5 text-white" />
</div>
<span className="min-w-0 grow truncate text-sm font-medium leading-5 text-text-primary">
{t('nodes.common.pluginsNotInstalled', { ns: 'workflow', count: items.length })}
</span>
<PopoverClose
render={(
<Button
variant="secondary"
size="small"
onClick={handleInstallAll}
disabled={dependencies.length === 0}
/>
)}
>
{t('nodes.agent.pluginInstaller.install', { ns: 'workflow' })}
</PopoverClose>
</div>
<div className="p-1">
{items.map(item => (
<div
key={item.id}
className="flex items-center gap-2 rounded-lg px-1"
>
<ItemIndicator />
<BlockIcon
type={item.type as BlockEnum}
size="xs"
toolIcon={item.toolIcon}
/>
<span className="min-w-0 grow truncate text-xs leading-4 text-text-warning">
{item.title}
</span>
</div>
))}
</div>
</div>
)
})
ChecklistPluginGroup.displayName = 'ChecklistPluginGroup'

View File

@ -0,0 +1,150 @@
import type { ReactNode } from 'react'
import { fireEvent, render, screen } from '@testing-library/react'
import * as React from 'react'
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import RunMode from './run-mode'
import { TriggerType } from './test-run-menu'
const mockHandleWorkflowStartRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerScheduleRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerWebhookRunInWorkflow = vi.fn()
const mockHandleWorkflowTriggerPluginRunInWorkflow = vi.fn()
const mockHandleWorkflowRunAllTriggersInWorkflow = vi.fn()
const mockHandleStopRun = vi.fn()
const mockNotify = vi.fn()
const mockTrackEvent = vi.fn()
let mockWarningNodes: Array<{ id: string }> = []
let mockWorkflowRunningData: { result: { status: WorkflowRunningStatus }, task_id: string } | undefined
let mockIsListening = false
let mockDynamicOptions = [
{ type: TriggerType.UserInput, nodeId: 'start-node' },
]
vi.mock('@/app/components/workflow/hooks', () => ({
useWorkflowStartRun: () => ({
handleWorkflowStartRunInWorkflow: mockHandleWorkflowStartRunInWorkflow,
handleWorkflowTriggerScheduleRunInWorkflow: mockHandleWorkflowTriggerScheduleRunInWorkflow,
handleWorkflowTriggerWebhookRunInWorkflow: mockHandleWorkflowTriggerWebhookRunInWorkflow,
handleWorkflowTriggerPluginRunInWorkflow: mockHandleWorkflowTriggerPluginRunInWorkflow,
handleWorkflowRunAllTriggersInWorkflow: mockHandleWorkflowRunAllTriggersInWorkflow,
}),
useWorkflowRun: () => ({
handleStopRun: mockHandleStopRun,
}),
useWorkflowRunValidation: () => ({
warningNodes: mockWarningNodes,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: { workflowRunningData?: unknown, isListening: boolean }) => unknown) =>
selector({ workflowRunningData: mockWorkflowRunningData, isListening: mockIsListening }),
}))
vi.mock('../hooks/use-dynamic-test-run-options', () => ({
useDynamicTestRunOptions: () => mockDynamicOptions,
}))
vi.mock('@/app/components/base/toast/context', () => ({
useToastContext: () => ({
notify: mockNotify,
}),
}))
vi.mock('@/app/components/base/amplitude', () => ({
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
}))
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: vi.fn(),
},
}),
}))
vi.mock('@/app/components/workflow/shortcuts-name', () => ({
default: () => <span data-testid="shortcuts-name">Shortcut</span>,
}))
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
StopCircle: () => <span data-testid="stop-circle" />,
}))
vi.mock('./test-run-menu', async (importOriginal) => {
const actual = await importOriginal<typeof import('./test-run-menu')>()
return {
...actual,
default: React.forwardRef(({ children, options, onSelect }: { children: ReactNode, options: Array<{ type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }>, onSelect: (option: { type: TriggerType, nodeId?: string, relatedNodeIds?: string[] }) => void }, ref) => {
React.useImperativeHandle(ref, () => ({
toggle: vi.fn(),
}))
return (
<div>
<button data-testid="trigger-option" onClick={() => onSelect(options[0])}>
Trigger option
</button>
{children}
</div>
)
}),
}
})
describe('RunMode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWarningNodes = []
mockWorkflowRunningData = undefined
mockIsListening = false
mockDynamicOptions = [
{ type: TriggerType.UserInput, nodeId: 'start-node' },
]
})
it('should render the run trigger and start the workflow when a valid trigger is selected', () => {
render(<RunMode />)
expect(screen.getByText(/run/i)).toBeInTheDocument()
fireEvent.click(screen.getByTestId('trigger-option'))
expect(mockHandleWorkflowStartRunInWorkflow).toHaveBeenCalledTimes(1)
expect(mockTrackEvent).toHaveBeenCalledWith('app_start_action_time', { action_type: 'user_input' })
})
it('should show an error toast instead of running when the selected trigger has checklist warnings', () => {
mockWarningNodes = [{ id: 'start-node' }]
render(<RunMode />)
fireEvent.click(screen.getByTestId('trigger-option'))
expect(mockNotify).toHaveBeenCalledWith({
type: 'error',
message: 'workflow.panel.checklistTip',
})
expect(mockHandleWorkflowStartRunInWorkflow).not.toHaveBeenCalled()
})
it('should render the running state and stop the workflow when it is already running', () => {
mockWorkflowRunningData = {
result: { status: WorkflowRunningStatus.Running },
task_id: 'task-1',
}
render(<RunMode />)
expect(screen.getByText(/running/i)).toBeInTheDocument()
fireEvent.click(screen.getByTestId('stop-circle').closest('button') as HTMLButtonElement)
expect(mockHandleStopRun).toHaveBeenCalledWith('task-1')
})
it('should render the listening label when the workflow is listening', () => {
mockIsListening = true
render(<RunMode />)
expect(screen.getByText(/listening/i)).toBeInTheDocument()
})
})

View File

@ -114,7 +114,7 @@ const RunMode = ({
<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',
'flex h-7 cursor-not-allowed items-center gap-x-1 rounded-l-md bg-state-accent-hover px-1.5 text-text-accent system-xs-medium',
)}
disabled={true}
>
@ -130,7 +130,7 @@ const RunMode = ({
>
<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',
'flex h-7 cursor-pointer items-center gap-x-1 rounded-md px-1.5 text-text-accent system-xs-medium hover:bg-state-accent-hover',
)}
style={{ userSelect: 'none' }}
>

View File

@ -1,8 +1,12 @@
import type { CommonNodeType, Node } from '../../types'
import type { ChecklistItem } from '../use-checklist'
import { screen, waitFor } from '@testing-library/react'
import { createElement, Fragment } from 'react'
import { CollectionType } from '@/app/components/tools/types'
import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures'
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { renderWorkflowComponent, renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { useStore } from '../../store'
import { BlockEnum } from '../../types'
import { useChecklist, useWorkflowRunValidation } from '../use-checklist'
@ -39,6 +43,9 @@ vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', ()
type CheckValidFn = (data: CommonNodeType, t: unknown, extra?: unknown) => { errorMessage: string }
const mockNodesMap: Record<string, { checkValid: CheckValidFn, metaData: { isStart: boolean, isRequired: boolean } }> = {}
let mockModelProviders: Array<{ provider: string }> = []
let mockUsedVars: string[][] = []
const mockAvailableVarMap: Record<string, { availableVars: Array<{ nodeId: string, vars: Array<{ variable: string }> }> }> = {}
vi.mock('../use-nodes-meta-data', () => ({
useNodesMetaData: () => ({
@ -49,10 +56,10 @@ vi.mock('../use-nodes-meta-data', () => ({
vi.mock('../use-nodes-available-var-list', () => ({
default: (nodes: Node[]) => {
const map: Record<string, { availableVars: never[] }> = {}
const map: Record<string, { availableVars: Array<{ nodeId: string, vars: Array<{ variable: string }> }> }> = {}
if (nodes) {
for (const n of nodes)
map[n.id] = { availableVars: [] }
map[n.id] = mockAvailableVarMap[n.id] ?? { availableVars: [] }
}
return map
},
@ -60,7 +67,7 @@ vi.mock('../use-nodes-available-var-list', () => ({
}))
vi.mock('../../nodes/_base/components/variable/utils', () => ({
getNodeUsedVars: () => [],
getNodeUsedVars: () => mockUsedVars,
isSpecialVar: () => false,
}))
@ -90,6 +97,11 @@ vi.mock('@/context/i18n', () => ({
useGetLanguage: () => 'en',
}))
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: (selector: (state: { modelProviders: Array<{ provider: string }> }) => unknown) =>
selector({ modelProviders: mockModelProviders }),
}))
// useWorkflowNodes reads from WorkflowContext (real store via renderWorkflowHook)
// ---------------------------------------------------------------------------
@ -124,6 +136,9 @@ beforeEach(() => {
resetReactFlowMockState()
resetFixtureCounters()
Object.keys(mockNodesMap).forEach(k => delete mockNodesMap[k])
Object.keys(mockAvailableVarMap).forEach(k => delete mockAvailableVarMap[k])
mockModelProviders = []
mockUsedVars = []
setupNodesMap()
})
@ -195,7 +210,7 @@ describe('useChecklist', () => {
const warning = result.current.find((item: ChecklistItem) => item.id === 'llm')
expect(warning).toBeDefined()
expect(warning!.errorMessage).toBe('Model not configured')
expect(warning!.errorMessages).toContain('Model not configured')
})
it('should report missing start node in workflow mode', () => {
@ -217,7 +232,9 @@ describe('useChecklist', () => {
data: {
type: BlockEnum.Tool,
title: 'My Tool',
_pluginInstallLocked: true,
provider_type: CollectionType.builtIn,
provider_id: 'missing-provider',
plugin_unique_identifier: 'plugin/tool@0.0.1',
},
})
@ -233,6 +250,9 @@ describe('useChecklist', () => {
expect(warning).toBeDefined()
expect(warning!.canNavigate).toBe(false)
expect(warning!.disableGoTo).toBe(true)
expect(warning!.isPluginMissing).toBe(true)
expect(warning!.pluginUniqueIdentifier).toBe('plugin/tool@0.0.1')
expect(warning!.errorMessages).toContain('workflow.nodes.common.pluginNotInstalled')
})
it('should report required node types that are missing', () => {
@ -279,6 +299,112 @@ describe('useChecklist', () => {
const alienWarning = result.current.find((item: ChecklistItem) => item.id === 'alien')
expect(alienWarning).toBeUndefined()
})
it('should report configure model errors when an llm model provider plugin is missing', () => {
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const llmNode = createNode({
id: 'llm',
data: {
type: BlockEnum.LLM,
title: 'LLM',
model: {
provider: 'langgenius/openai/openai',
},
},
})
const edges = [
createEdge({ source: 'start', target: 'llm' }),
]
const { result } = renderWorkflowHook(
() => useChecklist([startNode, llmNode], edges),
)
const warning = result.current.find((item: ChecklistItem) => item.id === 'llm')
expect(warning).toBeDefined()
expect(warning!.errorMessages).toContain('workflow.errorMsg.configureModel')
expect(warning!.canNavigate).toBe(true)
})
it('should accumulate validation and invalid variable errors for the same node', () => {
mockNodesMap[BlockEnum.LLM] = {
checkValid: () => ({ errorMessage: 'Model not configured' }),
metaData: { isStart: false, isRequired: false },
}
mockUsedVars = [['start', 'missingVar']]
mockAvailableVarMap.llm = {
availableVars: [
{
nodeId: 'start',
vars: [{ variable: 'existingVar' }],
},
],
}
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const llmNode = createNode({
id: 'llm',
data: {
type: BlockEnum.LLM,
title: 'LLM',
},
})
const edges = [
createEdge({ source: 'start', target: 'llm' }),
]
const { result } = renderWorkflowHook(
() => useChecklist([startNode, llmNode], edges),
)
const warning = result.current.find((item: ChecklistItem) => item.id === 'llm')
expect(warning).toBeDefined()
expect(warning!.errorMessages).toEqual([
'Model not configured',
'workflow.errorMsg.invalidVariable',
])
})
it('should sync checklist items to the workflow store without render phase update warnings', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
const codeNode = createNode({ id: 'code', data: { type: BlockEnum.Code, title: 'Code' } })
function Operator() {
const checklistItems = useStore(state => state.checklistItems)
return createElement('div', { 'data-testid': 'checklist-count' }, checklistItems.length)
}
function WorkflowChecklist() {
useChecklist([startNode, codeNode], [])
return null
}
const { store } = renderWorkflowComponent(
createElement(
Fragment,
null,
createElement(Operator),
createElement(WorkflowChecklist),
),
)
await waitFor(() => {
expect(store.getState().checklistItems).toHaveLength(1)
})
expect(screen.getByTestId('checklist-count')).toHaveTextContent('1')
expect(errorSpy.mock.calls.some(call =>
call.some(arg => typeof arg === 'string' && arg.includes('Cannot update a component')),
)).toBe(false)
}
finally {
errorSpy.mockRestore()
}
})
})
// ---------------------------------------------------------------------------

View File

@ -0,0 +1,253 @@
import type { CommonNodeType } from '../../types'
import { act } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import { renderWorkflowHook } from '../../__tests__/workflow-test-env'
import { BlockEnum } from '../../types'
import { useNodePluginInstallation } from '../use-node-plugin-installation'
const mockBuiltInTools = vi.fn()
const mockCustomTools = vi.fn()
const mockWorkflowTools = vi.fn()
const mockMcpTools = vi.fn()
const mockInvalidToolsByType = vi.fn()
const mockTriggerPlugins = vi.fn()
const mockInvalidateTriggers = vi.fn()
const mockInvalidDataSourceList = vi.fn()
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: (enabled: boolean) => mockBuiltInTools(enabled),
useAllCustomTools: (enabled: boolean) => mockCustomTools(enabled),
useAllWorkflowTools: (enabled: boolean) => mockWorkflowTools(enabled),
useAllMCPTools: (enabled: boolean) => mockMcpTools(enabled),
useInvalidToolsByType: (providerType?: string) => mockInvalidToolsByType(providerType),
}))
vi.mock('@/service/use-triggers', () => ({
useAllTriggerPlugins: (enabled: boolean) => mockTriggerPlugins(enabled),
useInvalidateAllTriggerPlugins: () => mockInvalidateTriggers,
}))
vi.mock('@/service/use-pipeline', () => ({
useInvalidDataSourceList: () => mockInvalidDataSourceList,
}))
const makeToolNode = (overrides: Partial<CommonNodeType> = {}) => ({
type: BlockEnum.Tool,
title: 'Tool node',
desc: '',
provider_type: CollectionType.builtIn,
provider_id: 'search',
provider_name: 'search',
plugin_id: 'plugin-search',
plugin_unique_identifier: 'plugin-search@1.0.0',
...overrides,
}) as CommonNodeType
const makeTriggerNode = (overrides: Partial<CommonNodeType> = {}) => ({
type: BlockEnum.TriggerPlugin,
title: 'Trigger node',
desc: '',
provider_id: 'trigger-provider',
provider_name: 'trigger-provider',
plugin_id: 'trigger-plugin',
plugin_unique_identifier: 'trigger-plugin@1.0.0',
...overrides,
}) as CommonNodeType
const makeDataSourceNode = (overrides: Partial<CommonNodeType> = {}) => ({
type: BlockEnum.DataSource,
title: 'Data source node',
desc: '',
provider_name: 'knowledge-provider',
plugin_id: 'knowledge-plugin',
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
...overrides,
}) as CommonNodeType
const matchedTool = {
plugin_id: 'plugin-search',
provider: 'search',
name: 'search',
plugin_unique_identifier: 'plugin-search@1.0.0',
}
const matchedTriggerProvider = {
id: 'trigger-provider',
name: 'trigger-provider',
plugin_id: 'trigger-plugin',
}
const matchedDataSource = {
provider: 'knowledge-provider',
plugin_id: 'knowledge-plugin',
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
}
describe('useNodePluginInstallation', () => {
beforeEach(() => {
vi.clearAllMocks()
mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: false })
mockCustomTools.mockReturnValue({ data: undefined, isLoading: false })
mockWorkflowTools.mockReturnValue({ data: undefined, isLoading: false })
mockMcpTools.mockReturnValue({ data: undefined, isLoading: false })
mockInvalidToolsByType.mockReturnValue(undefined)
mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: false })
mockInvalidateTriggers.mockReset()
mockInvalidDataSourceList.mockReset()
})
it('should return the noop installation state for non plugin-dependent nodes', () => {
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation({
type: BlockEnum.LLM,
title: 'LLM',
desc: '',
} as CommonNodeType),
)
expect(result.current).toEqual({
isChecking: false,
isMissing: false,
uniqueIdentifier: undefined,
canInstall: false,
onInstallSuccess: expect.any(Function),
shouldDim: false,
})
})
it('should report loading and invalidate built-in tools while the collection is resolving', () => {
const invalidateTools = vi.fn()
mockBuiltInTools.mockReturnValue({ data: undefined, isLoading: true })
mockInvalidToolsByType.mockReturnValue(invalidateTools)
const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeToolNode()))
expect(mockBuiltInTools).toHaveBeenCalledWith(true)
expect(result.current.isChecking).toBe(true)
expect(result.current.isMissing).toBe(false)
expect(result.current.uniqueIdentifier).toBe('plugin-search@1.0.0')
expect(result.current.canInstall).toBe(true)
expect(result.current.shouldDim).toBe(true)
act(() => {
result.current.onInstallSuccess()
})
expect(invalidateTools).toHaveBeenCalled()
})
it.each([
[CollectionType.custom, mockCustomTools],
[CollectionType.workflow, mockWorkflowTools],
[CollectionType.mcp, mockMcpTools],
])('should resolve matched %s tool collections without dimming', (providerType, hookMock) => {
hookMock.mockReturnValue({ data: [matchedTool], isLoading: false })
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeToolNode({ provider_type: providerType })),
)
expect(result.current.isChecking).toBe(false)
expect(result.current.isMissing).toBe(false)
expect(result.current.shouldDim).toBe(false)
})
it('should keep unknown tool collection types installable without collection state', () => {
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeToolNode({
provider_type: 'unknown' as CollectionType,
plugin_unique_identifier: undefined,
plugin_id: undefined,
provider_id: 'legacy-provider',
})),
)
expect(result.current.isChecking).toBe(false)
expect(result.current.isMissing).toBe(false)
expect(result.current.uniqueIdentifier).toBe('legacy-provider')
expect(result.current.canInstall).toBe(false)
expect(result.current.shouldDim).toBe(false)
})
it('should flag missing trigger plugins and invalidate trigger data after installation', () => {
mockTriggerPlugins.mockReturnValue({ data: [matchedTriggerProvider], isLoading: false })
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeTriggerNode({
provider_id: 'missing-trigger',
provider_name: 'missing-trigger',
plugin_id: 'missing-trigger',
})),
)
expect(mockTriggerPlugins).toHaveBeenCalledWith(true)
expect(result.current.isChecking).toBe(false)
expect(result.current.isMissing).toBe(true)
expect(result.current.shouldDim).toBe(true)
act(() => {
result.current.onInstallSuccess()
})
expect(mockInvalidateTriggers).toHaveBeenCalled()
})
it('should treat the trigger plugin list as still loading when it has not resolved yet', () => {
mockTriggerPlugins.mockReturnValue({ data: undefined, isLoading: true })
const { result } = renderWorkflowHook(() =>
useNodePluginInstallation(makeTriggerNode({ plugin_unique_identifier: undefined, plugin_id: 'trigger-plugin' })),
)
expect(result.current.isChecking).toBe(true)
expect(result.current.isMissing).toBe(false)
expect(result.current.uniqueIdentifier).toBe('trigger-plugin')
expect(result.current.canInstall).toBe(false)
expect(result.current.shouldDim).toBe(true)
})
it('should track missing and matched data source providers based on workflow store state', () => {
const missingRender = renderWorkflowHook(
() => useNodePluginInstallation(makeDataSourceNode({
provider_name: 'missing-provider',
plugin_id: 'missing-plugin',
plugin_unique_identifier: 'missing-plugin@1.0.0',
})),
{
initialStoreState: {
dataSourceList: [matchedDataSource] as never,
},
},
)
expect(missingRender.result.current.isChecking).toBe(false)
expect(missingRender.result.current.isMissing).toBe(true)
expect(missingRender.result.current.shouldDim).toBe(true)
const matchedRender = renderWorkflowHook(
() => useNodePluginInstallation(makeDataSourceNode()),
{
initialStoreState: {
dataSourceList: [matchedDataSource] as never,
},
},
)
expect(matchedRender.result.current.isMissing).toBe(false)
expect(matchedRender.result.current.shouldDim).toBe(false)
act(() => {
matchedRender.result.current.onInstallSuccess()
})
expect(mockInvalidDataSourceList).toHaveBeenCalled()
})
it('should keep data sources in checking state before the list is loaded', () => {
const { result } = renderWorkflowHook(() => useNodePluginInstallation(makeDataSourceNode()))
expect(result.current.isChecking).toBe(true)
expect(result.current.isMissing).toBe(false)
expect(result.current.shouldDim).toBe(true)
})
})

View File

@ -8,14 +8,19 @@ import type {
CommonEdgeType,
CommonNodeType,
Edge,
ModelConfig,
Node,
ValueSelector,
} from '../types'
import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Emoji } from '@/app/components/tools/types'
import type { DataSet } from '@/models/datasets'
import type { I18nKeysWithPrefix } from '@/types/i18n'
import { useQueries, useQueryClient } from '@tanstack/react-query'
import isDeepEqual from 'fast-deep-equal'
import {
useCallback,
useEffect,
useMemo,
useRef,
} from 'react'
@ -28,11 +33,14 @@ import { useModelList } from '@/app/components/header/account-setting/model-prov
import useNodes from '@/app/components/workflow/store/workflow/use-nodes'
import { MAX_TREE_DEPTH } from '@/config'
import { useGetLanguage } from '@/context/i18n'
import { useProviderContextSelector } from '@/context/provider-context'
import { consoleQuery } from '@/service/client'
import { fetchDatasets } from '@/service/datasets'
import { useStrategyProviders } from '@/service/use-strategy'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
} from '@/service/use-tools'
import { useAllTriggerPlugins } from '@/service/use-triggers'
@ -46,6 +54,8 @@ import {
useNodesMetaData,
} from '../hooks'
import { getNodeUsedVars, isSpecialVar } from '../nodes/_base/components/variable/utils'
import { IndexMethodEnum } from '../nodes/knowledge-base/types'
import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from '../nodes/llm/utils'
import {
useStore,
useWorkflowStore,
@ -56,6 +66,8 @@ import {
getToolCheckParams,
getValidTreeNodes,
} from '../utils'
import { extractPluginId } from '../utils/plugin'
import { isNodePluginMissing } from '../utils/plugin-install-check'
import { getTriggerCheckParams } from '../utils/trigger'
import useNodesAvailableVarList, { useGetNodesAvailableVarList } from './use-nodes-available-var-list'
@ -65,9 +77,11 @@ export type ChecklistItem = {
title: string
toolIcon?: string | Emoji
unConnected?: boolean
errorMessage?: string
errorMessages: string[]
canNavigate: boolean
disableGoTo?: boolean
isPluginMissing?: boolean
pluginUniqueIdentifier?: string
}
const START_NODE_TYPES: BlockEnum[] = [
@ -77,13 +91,6 @@ const START_NODE_TYPES: BlockEnum[] = [
BlockEnum.TriggerPlugin,
]
// Node types that depend on plugins
const PLUGIN_DEPENDENT_TYPES: BlockEnum[] = [
BlockEnum.Tool,
BlockEnum.DataSource,
BlockEnum.TriggerPlugin,
]
export const useChecklist = (nodes: Node[], edges: Edge[]) => {
const { t } = useTranslation()
const language = useGetLanguage()
@ -91,6 +98,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const dataSourceList = useStore(s => s.dataSourceList)
const { data: strategyProviders } = useStrategyProviders()
const { data: triggerPlugins } = useAllTriggerPlugins()
@ -98,10 +106,49 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
const getToolIcon = useGetToolIcon()
const appMode = useAppStore.getState().appDetail?.mode
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
const modelProviders = useProviderContextSelector(s => s.modelProviders)
const workflowStore = useWorkflowStore()
const map = useNodesAvailableVarList(nodes)
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const knowledgeBaseEmbeddingProviders = useMemo(() => {
const providers = new Set<string>()
nodes.forEach((node) => {
if (node.type !== CUSTOM_NODE || node.data.type !== BlockEnum.KnowledgeBase)
return
const knowledgeBaseData = node.data as CommonNodeType<KnowledgeBaseNodeType>
if (knowledgeBaseData.indexing_technique !== IndexMethodEnum.QUALIFIED)
return
const provider = knowledgeBaseData.embedding_model_provider
if (provider)
providers.add(provider)
})
return [...providers]
}, [nodes])
const knowledgeBaseProviderModelMap = useQueries({
queries: knowledgeBaseEmbeddingProviders.map(provider =>
consoleQuery.modelProviders.models.queryOptions({
input: { params: { provider } },
enabled: !!provider,
refetchOnWindowFocus: false,
select: response => response.data,
}),
),
combine: (results) => {
const modelMap: Partial<Record<string, ModelItem[]>> = {}
knowledgeBaseEmbeddingProviders.forEach((provider, index) => {
const models = results[index]?.data
if (models)
modelMap[provider] = models
})
return modelMap
},
})
const getCheckData = useCallback((data: CommonNodeType<{}>) => {
let checkData = data
@ -118,19 +165,22 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
} as CommonNodeType<KnowledgeRetrievalNodeType>
}
else if (data.type === BlockEnum.KnowledgeBase) {
const modelProviderName = (data as CommonNodeType<KnowledgeBaseNodeType>).embedding_model_provider
checkData = {
...data,
_embeddingModelList: embeddingModelList,
_embeddingProviderModelList: modelProviderName ? knowledgeBaseProviderModelMap[modelProviderName] : undefined,
_rerankModelList: rerankModelList,
} as CommonNodeType<KnowledgeBaseNodeType>
}
return checkData
}, [datasetsDetail, embeddingModelList, rerankModelList])
}, [datasetsDetail, embeddingModelList, knowledgeBaseProviderModelMap, rerankModelList])
const needWarningNodes = useMemo<ChecklistItem[]>(() => {
const list: ChecklistItem[] = []
const filteredNodes = nodes.filter(node => node.type === CUSTOM_NODE)
const { validNodes } = getValidTreeNodes(filteredNodes, edges)
const installedPluginIds = new Set(modelProviders.map(p => extractPluginId(p.provider)))
for (let i = 0; i < filteredNodes.length; i++) {
const node = filteredNodes[i]
@ -166,41 +216,50 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
if (node.type === CUSTOM_NODE) {
const checkData = getCheckData(node.data)
const validator = nodesExtraData?.[node.data.type as BlockEnum]?.checkValid
const isPluginMissing = PLUGIN_DEPENDENT_TYPES.includes(node.data.type as BlockEnum) && node.data._pluginInstallLocked
const isPluginMissing = isNodePluginMissing(node.data, { builtInTools: buildInTools, customTools, workflowTools, mcpTools, triggerPlugins, dataSourceList })
// Check if plugin is installed for plugin-dependent nodes first
let errorMessage: string | undefined
if (isPluginMissing)
errorMessage = t('nodes.common.pluginNotInstalled', { ns: 'workflow' })
else if (validator)
errorMessage = validator(checkData, t, moreDataForCheckValid).errorMessage
const errorMessages: string[] = []
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('errorMsg.invalidVariable', { ns: 'workflow' })
}
else {
errorMessage = t('errorMsg.invalidVariable', { ns: 'workflow' })
}
}
if (isPluginMissing) {
errorMessages.push(t('nodes.common.pluginNotInstalled', { ns: 'workflow' }))
}
else {
if (node.data.type === BlockEnum.LLM) {
const modelProvider = (node.data as CommonNodeType<{ model?: ModelConfig }>).model?.provider
const modelIssue = getLLMModelIssue({
modelProvider,
isModelProviderInstalled: isLLMModelProviderInstalled(modelProvider, installedPluginIds),
})
if (modelIssue === LLMModelIssueCode.providerPluginUnavailable)
errorMessages.push(t('errorMsg.configureModel', { ns: 'workflow' }))
}
if (validator) {
const validationError = validator(checkData, t, moreDataForCheckValid).errorMessage
if (validationError)
errorMessages.push(validationError)
}
const availableVars = map[node.id].availableVars
let hasInvalidVar = false
for (const variable of usedVars) {
if (hasInvalidVar)
break
if (isSpecialVar(variable[0]))
continue
const usedNode = availableVars.find(v => v.nodeId === variable?.[0])
if (!usedNode || !usedNode.vars.some(v => v.variable === variable?.[1]))
hasInvalidVar = true
}
if (hasInvalidVar)
errorMessages.push(t('errorMsg.invalidVariable', { ns: 'workflow' }))
}
// 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)
const isUnconnected = !validNodes.some(n => n.id === node.id)
const shouldShowError = errorMessages.length > 0 || (isUnconnected && !canSkipConnectionCheck)
if (shouldShowError) {
list.push({
@ -209,9 +268,13 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
title: node.data.title,
toolIcon,
unConnected: isUnconnected && !canSkipConnectionCheck,
errorMessage,
errorMessages,
canNavigate: !isPluginMissing,
disableGoTo: isPluginMissing,
isPluginMissing,
pluginUniqueIdentifier: isPluginMissing
? (node.data as { plugin_unique_identifier?: string }).plugin_unique_identifier
: undefined,
})
}
}
@ -225,7 +288,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
id: 'start-node-required',
type: BlockEnum.Start,
title: t('panel.startNode', { ns: 'workflow' }),
errorMessage: t('common.needStartNode', { ns: 'workflow' }),
errorMessages: [t('common.needStartNode', { ns: 'workflow' })],
canNavigate: false,
})
}
@ -234,22 +297,27 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
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)) {
if (!filteredNodes.some(node => node.data.type === type)) {
list.push({
id: `${type}-need-added`,
type,
// We don't have enough type info for t() here
title: t(`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>, { ns: 'workflow' }),
errorMessage: t('common.needAdd', { ns: 'workflow', node: t(`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>, { ns: 'workflow' }) }),
errorMessages: [t('common.needAdd', { ns: 'workflow', node: t(`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>, { ns: 'workflow' }) })],
canNavigate: false,
})
}
})
return list
}, [nodes, edges, shouldCheckStartNode, nodesExtraData, buildInTools, customTools, workflowTools, language, dataSourceList, triggerPlugins, getToolIcon, strategyProviders, getCheckData, t, map])
}, [nodes, edges, shouldCheckStartNode, nodesExtraData, buildInTools, customTools, workflowTools, mcpTools, language, dataSourceList, triggerPlugins, getToolIcon, strategyProviders, getCheckData, t, map, modelProviders])
useEffect(() => {
const currentChecklistItems = workflowStore.getState().checklistItems
if (isDeepEqual(currentChecklistItems, needWarningNodes))
return
workflowStore.setState({ checklistItems: needWarningNodes })
}, [needWarningNodes, workflowStore])
return needWarningNodes
}
@ -258,11 +326,13 @@ export const useChecklistBeforePublish = () => {
const { t } = useTranslation()
const language = useGetLanguage()
const { notify } = useToastContext()
const queryClient = useQueryClient()
const store = useStoreApi()
const { nodesMap: nodesExtraData } = useNodesMetaData()
const { data: strategyProviders } = useStrategyProviders()
const modelProviders = useProviderContextSelector(s => s.modelProviders)
const updateDatasetsDetail = useDatasetsDetailStore(s => s.updateDatasetsDetail)
const updateTime = useRef(0)
const updateTimeRef = useRef(0)
const workflowStore = useWorkflowStore()
const { getNodesAvailableVarList } = useGetNodesAvailableVarList()
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
@ -273,7 +343,11 @@ export const useChecklistBeforePublish = () => {
const appMode = useAppStore.getState().appDetail?.mode
const shouldCheckStartNode = appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
const getCheckData = useCallback((data: CommonNodeType<{}>, datasets: DataSet[]) => {
const getCheckData = useCallback((
data: CommonNodeType<object>,
datasets: DataSet[],
embeddingProviderModelMap?: Partial<Record<string, ModelItem[]>>,
) => {
let checkData = data
if (data.type === BlockEnum.KnowledgeRetrieval) {
const datasetIds = (data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids
@ -292,9 +366,11 @@ export const useChecklistBeforePublish = () => {
} as CommonNodeType<KnowledgeRetrievalNodeType>
}
else if (data.type === BlockEnum.KnowledgeBase) {
const modelProviderName = (data as CommonNodeType<KnowledgeBaseNodeType>).embedding_model_provider
checkData = {
...data,
_embeddingModelList: embeddingModelList,
_embeddingProviderModelList: modelProviderName ? embeddingProviderModelMap?.[modelProviderName] : undefined,
_rerankModelList: rerankModelList,
} as CommonNodeType<KnowledgeBaseNodeType>
}
@ -317,24 +393,67 @@ export const useChecklistBeforePublish = () => {
notify({ type: 'error', message: t('common.maxTreeDepth', { ns: 'workflow', 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 knowledgeBaseEmbeddingProviders = [...new Set(
filteredNodes
.filter(node => node.data.type === BlockEnum.KnowledgeBase)
.map(node => node.data as CommonNodeType<KnowledgeBaseNodeType>)
.filter(node => node.indexing_technique === IndexMethodEnum.QUALIFIED)
.map(node => node.embedding_model_provider)
.filter((provider): provider is string => !!provider),
)]
const fetchKnowledgeBaseProviderModelMap = async () => {
const modelMap: Partial<Record<string, ModelItem[]>> = {}
await Promise.all(knowledgeBaseEmbeddingProviders.map(async (provider) => {
try {
const modelList = await queryClient.fetchQuery(
consoleQuery.modelProviders.models.queryOptions({
input: { params: { provider } },
}),
)
if (modelList.data)
modelMap[provider] = modelList.data
}
catch {
}
}))
return modelMap
}
const fetchLatestDatasets = async (): Promise<DataSet[] | null> => {
const allDatasetIds = new Set<string>()
filteredNodes.forEach((node) => {
if (node.data.type !== BlockEnum.KnowledgeRetrieval)
return
const datasetIds = (node.data as CommonNodeType<KnowledgeRetrievalNodeType>).dataset_ids
datasetIds.forEach(id => allDatasetIds.add(id))
})
if (allDatasetIds.size === 0)
return []
updateTimeRef.current = updateTimeRef.current + 1
const currUpdateTime = updateTimeRef.current
const { data: datasetsDetail } = await fetchDatasets({ url: '/datasets', params: { page: 1, ids: [...allDatasetIds] } })
if (currUpdateTime < updateTimeRef.current)
return null
if (datasetsDetail?.length)
updateDatasetsDetail(datasetsDetail)
return datasetsDetail || []
}
const [embeddingProviderModelMap, datasets] = await Promise.all([
fetchKnowledgeBaseProviderModelMap(),
fetchLatestDatasets(),
])
if (datasets === null)
return false
const installedPluginIds = new Set(modelProviders.map(p => extractPluginId(p.provider)))
const map = getNodesAvailableVarList(nodes)
for (let i = 0; i < filteredNodes.length; i++) {
const node = filteredNodes[i]
@ -361,7 +480,20 @@ export const useChecklistBeforePublish = () => {
else {
usedVars = getNodeUsedVars(node).filter(v => v.length > 0)
}
const checkData = getCheckData(node.data, datasets)
if (node.data.type === BlockEnum.LLM) {
const modelProvider = (node.data as CommonNodeType<{ model?: ModelConfig }>).model?.provider
const modelIssue = getLLMModelIssue({
modelProvider,
isModelProviderInstalled: isLLMModelProviderInstalled(modelProvider, installedPluginIds),
})
if (modelIssue === LLMModelIssueCode.providerPluginUnavailable) {
notify({ type: 'error', message: `[${node.data.title}] ${t('errorMsg.configureModel', { ns: 'workflow' })}` })
return false
}
}
const checkData = getCheckData(node.data, datasets, embeddingProviderModelMap)
const { errorMessage } = nodesExtraData![node.data.type as BlockEnum].checkValid(checkData, t, moreDataForCheckValid)
if (errorMessage) {
@ -391,7 +523,7 @@ export const useChecklistBeforePublish = () => {
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 isUnconnected = !validNodes.some(n => n.id === node.id)
if (isUnconnected && !canSkipConnectionCheck) {
notify({ type: 'error', message: `[${node.data.title}] ${t('common.needConnectTip', { ns: 'workflow' })}` })
@ -412,14 +544,14 @@ export const useChecklistBeforePublish = () => {
for (let i = 0; i < isRequiredNodesType.length; i++) {
const type = isRequiredNodesType[i]
if (!filteredNodes.find(node => node.data.type === type)) {
if (!filteredNodes.some(node => node.data.type === type)) {
notify({ type: 'error', message: t('common.needAdd', { ns: 'workflow', node: t(`blocks.${type}` as I18nKeysWithPrefix<'workflow', 'blocks.'>, { ns: 'workflow' }) }) })
return false
}
}
return true
}, [store, workflowStore, getNodesAvailableVarList, shouldCheckStartNode, nodesExtraData, notify, t, updateDatasetsDetail, buildInTools, customTools, workflowTools, language, getCheckData, strategyProviders])
}, [store, workflowStore, getNodesAvailableVarList, shouldCheckStartNode, nodesExtraData, notify, t, updateDatasetsDetail, buildInTools, customTools, workflowTools, language, getCheckData, queryClient, strategyProviders, modelProviders])
return {
handleCheckBeforePublish,

View File

@ -16,11 +16,15 @@ import {
useAllTriggerPlugins,
useInvalidateAllTriggerPlugins,
} from '@/service/use-triggers'
import { canFindTool } from '@/utils'
import { useStore } from '../store'
import { BlockEnum } from '../types'
import {
matchDataSource,
matchToolInCollection,
matchTriggerProvider,
} from '../utils/plugin-install-check'
type InstallationState = {
export type InstallationState = {
isChecking: boolean
isMissing: boolean
uniqueIdentifier?: string
@ -29,14 +33,31 @@ type InstallationState = {
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 NOOP_INSTALLATION: InstallationState = {
isChecking: false,
isMissing: false,
uniqueIdentifier: undefined,
canInstall: false,
onInstallSuccess: () => undefined,
shouldDim: false,
}
const useToolInstallation = (data: ToolNodeType, enabled: boolean): InstallationState => {
const isBuiltIn = enabled && data.provider_type === CollectionType.builtIn
const isCustom = enabled && data.provider_type === CollectionType.custom
const isWorkflow = enabled && data.provider_type === CollectionType.workflow
const isMcp = enabled && data.provider_type === CollectionType.mcp
const builtInQuery = useAllBuiltInTools(isBuiltIn)
const customQuery = useAllCustomTools(isCustom)
const workflowQuery = useAllWorkflowTools(isWorkflow)
const mcpQuery = useAllMCPTools(isMcp)
const invalidateTools = useInvalidToolsByType(enabled ? data.provider_type : undefined)
const collectionInfo = useMemo(() => {
if (!enabled)
return undefined
switch (data.provider_type) {
case CollectionType.builtIn:
return {
@ -62,6 +83,7 @@ const useToolInstallation = (data: ToolNodeType): InstallationState => {
return undefined
}
}, [
enabled,
builtInQuery.data,
builtInQuery.isLoading,
customQuery.data,
@ -77,20 +99,13 @@ const useToolInstallation = (data: ToolNodeType): InstallationState => {
const isLoading = collectionInfo?.isLoading ?? false
const isResolved = !!collectionInfo && !isLoading
const { plugin_id, provider_id, provider_name } = data
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])
return matchToolInCollection(collection, { plugin_id, provider_id, provider_name })
}, [collection, plugin_id, provider_id, provider_name])
const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id
const canInstall = Boolean(data.plugin_unique_identifier)
@ -112,28 +127,20 @@ const useToolInstallation = (data: ToolNodeType): InstallationState => {
}
}
const useTriggerInstallation = (data: PluginTriggerNodeType): InstallationState => {
const triggerPluginsQuery = useAllTriggerPlugins()
const useTriggerInstallation = (data: PluginTriggerNodeType, enabled: boolean): InstallationState => {
const triggerPluginsQuery = useAllTriggerPlugins(enabled)
const invalidateTriggers = useInvalidateAllTriggerPlugins()
const triggerProviders = triggerPluginsQuery.data
const isLoading = triggerPluginsQuery.isLoading
const { plugin_id, provider_id, provider_name } = data
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,
])
return matchTriggerProvider(triggerProviders, { plugin_id, provider_id, provider_name })
}, [plugin_id, provider_id, provider_name, triggerProviders])
const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id || data.provider_id
const canInstall = Boolean(data.plugin_unique_identifier)
@ -154,24 +161,17 @@ const useTriggerInstallation = (data: PluginTriggerNodeType): InstallationState
}
}
const useDataSourceInstallation = (data: DataSourceNodeType): InstallationState => {
const useDataSourceInstallation = (data: DataSourceNodeType, _enabled: boolean): InstallationState => {
const dataSourceList = useStore(s => s.dataSourceList)
const invalidateDataSourceList = useInvalidDataSourceList()
const { plugin_unique_identifier, plugin_id, provider_name } = data
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])
return matchDataSource(dataSourceList, { plugin_unique_identifier, plugin_id, provider_name })
}, [dataSourceList, plugin_id, plugin_unique_identifier, provider_name])
const uniqueIdentifier = data.plugin_unique_identifier || data.plugin_id
const canInstall = Boolean(data.plugin_unique_identifier)
@ -195,25 +195,20 @@ const useDataSourceInstallation = (data: DataSourceNodeType): InstallationState
}
export const useNodePluginInstallation = (data: CommonNodeType): InstallationState => {
const toolInstallation = useToolInstallation(data as ToolNodeType)
const triggerInstallation = useTriggerInstallation(data as PluginTriggerNodeType)
const dataSourceInstallation = useDataSourceInstallation(data as DataSourceNodeType)
const isTool = data.type === BlockEnum.Tool
const isTrigger = data.type === BlockEnum.TriggerPlugin
const isDataSource = data.type === BlockEnum.DataSource
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,
}
}
const toolInstallation = useToolInstallation(data as ToolNodeType, isTool)
const triggerInstallation = useTriggerInstallation(data as PluginTriggerNodeType, isTrigger)
const dataSourceInstallation = useDataSourceInstallation(data as DataSourceNodeType, isDataSource)
if (isTool)
return toolInstallation
if (isTrigger)
return triggerInstallation
if (isDataSource)
return dataSourceInstallation
return NOOP_INSTALLATION
}

View File

@ -420,8 +420,6 @@ export const useNodesInteractions = () => {
return
if (node.data.type === BlockEnum.DataSourceEmpty)
return
if (node.data._pluginInstallLocked)
return
handleNodeSelect(node.id)
},
[handleNodeSelect],

View File

@ -0,0 +1,56 @@
import { fireEvent, render, screen } from '@testing-library/react'
import Field from './field'
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ popupContent }: { popupContent: React.ReactNode }) => <div>{popupContent}</div>,
}))
describe('Field', () => {
it('should render subtitle styling, tooltip, operations, warning dot and required marker', () => {
const { container } = render(
<Field
title="Knowledge"
tooltip="tooltip text"
operations={<button type="button">operation</button>}
required
warningDot
isSubTitle
/>,
)
expect(screen.getByText('Knowledge')).toBeInTheDocument()
expect(screen.getByText('tooltip text')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'operation' })).toBeInTheDocument()
expect(screen.getByText('*')).toBeInTheDocument()
expect(container.querySelector('.system-xs-medium-uppercase')).not.toBeNull()
expect(container.querySelector('.bg-text-warning-secondary')).not.toBeNull()
})
it('should toggle folded children when supportFold is enabled', () => {
const { container } = render(
<Field title="Foldable" supportFold>
<div>folded content</div>
</Field>,
)
expect(screen.queryByText('folded content')).not.toBeInTheDocument()
fireEvent.click(screen.getByText('Foldable').closest('.cursor-pointer')!)
expect(screen.getByText('folded content')).toBeInTheDocument()
expect(container.querySelector('svg')).toHaveStyle({ transform: 'rotate(0deg)' })
fireEvent.click(screen.getByText('Foldable').closest('.cursor-pointer')!)
expect(screen.queryByText('folded content')).not.toBeInTheDocument()
})
it('should render inline children without folding support', () => {
const { container } = render(
<Field title="Inline" inline>
<div>always visible</div>
</Field>,
)
expect(screen.getByText('always visible')).toBeInTheDocument()
expect(container.firstChild).toHaveClass('flex')
})
})

View File

@ -18,6 +18,7 @@ type Props = {
operations?: React.JSX.Element
inline?: boolean
required?: boolean
warningDot?: boolean
}
const Field: FC<Props> = ({
@ -30,6 +31,7 @@ const Field: FC<Props> = ({
inline,
supportFold,
required,
warningDot,
}) => {
const [fold, {
toggle: toggleFold,
@ -41,7 +43,10 @@ const Field: FC<Props> = ({
className={cn('flex items-center justify-between', supportFold && 'cursor-pointer')}
>
<div className="flex h-6 items-center">
<div className={cn(isSubTitle ? 'system-xs-medium-uppercase text-text-tertiary' : 'system-sm-semibold-uppercase text-text-secondary')}>
<div className={cn('relative', isSubTitle ? 'text-text-tertiary system-xs-medium-uppercase' : 'text-text-secondary system-sm-semibold-uppercase')}>
{warningDot && (
<span className="absolute -left-[9px] top-1/2 size-[5px] -translate-y-1/2 rounded-full bg-text-warning-secondary" />
)}
{title}
{' '}
{required && <span className="text-text-destructive">*</span>}

View File

@ -0,0 +1,67 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { FieldTitle } from './field-title'
vi.mock('@/app/components/base/ui/tooltip', () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}))
describe('FieldTitle', () => {
it('should render title, subtitle, operation, tooltip and warning dot', () => {
render(
<FieldTitle
title="Embedding"
subTitle={<div>subtitle</div>}
operation={<button type="button">action</button>}
tooltip="Tooltip copy"
warningDot
/>,
)
expect(screen.getByText('Embedding')).toBeInTheDocument()
expect(screen.getByText('subtitle')).toBeInTheDocument()
expect(screen.getByText('Tooltip copy')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'action' })).toBeInTheDocument()
expect(document.querySelector('.bg-text-warning-secondary')).not.toBeNull()
})
it('should toggle local collapsed state and notify onCollapse when enabled', () => {
const onCollapse = vi.fn()
const { container } = render(
<FieldTitle
title="Models"
showArrow
onCollapse={onCollapse}
/>,
)
const header = screen.getByText('Models').closest('.group\\/collapse')
const arrow = container.querySelector('[aria-hidden="true"]')
expect(arrow).toHaveClass('rotate-[270deg]')
fireEvent.click(header!)
expect(onCollapse).toHaveBeenCalledWith(false)
expect(arrow).not.toHaveClass('rotate-[270deg]')
})
it('should respect controlled collapsed state and ignore clicks when disabled', () => {
const onCollapse = vi.fn()
const { container } = render(
<FieldTitle
title="Controlled"
showArrow
collapsed={false}
disabled
onCollapse={onCollapse}
/>,
)
fireEvent.click(screen.getByText('Controlled').closest('.group\\/collapse')!)
expect(onCollapse).not.toHaveBeenCalled()
expect(container.querySelector('[aria-hidden="true"]')).not.toHaveClass('rotate-[270deg]')
})
})

View File

@ -3,8 +3,7 @@ import {
memo,
useState,
} from 'react'
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
import Tooltip from '@/app/components/base/tooltip'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { cn } from '@/utils/classnames'
export type FieldTitleProps = {
@ -12,6 +11,7 @@ export type FieldTitleProps = {
operation?: ReactNode
subTitle?: string | ReactNode
tooltip?: string
warningDot?: boolean
showArrow?: boolean
disabled?: boolean
collapsed?: boolean
@ -22,6 +22,7 @@ export const FieldTitle = memo(({
operation,
subTitle,
tooltip,
warningDot,
showArrow,
disabled,
collapsed,
@ -41,13 +42,19 @@ export const FieldTitle = memo(({
}
}}
>
<div className="system-sm-semibold-uppercase flex items-center text-text-secondary">
{title}
<div className="flex items-center text-text-secondary system-sm-semibold-uppercase">
<span className="relative">
{warningDot && (
<span className="absolute -left-[9px] top-1/2 size-[5px] -translate-y-1/2 rounded-full bg-text-warning-secondary" />
)}
{title}
</span>
{
showArrow && (
<ArrowDownRoundFill
<span
aria-hidden
className={cn(
'h-4 w-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
'i-custom-vender-solid-general-arrow-down-round-fill h-4 w-4 cursor-pointer text-text-quaternary group-hover/collapse:text-text-secondary',
collapsedMerged && 'rotate-[270deg]',
)}
/>
@ -55,10 +62,19 @@ export const FieldTitle = memo(({
}
{
tooltip && (
<Tooltip
popupContent={tooltip}
triggerClassName="w-4 h-4 ml-1"
/>
<Tooltip>
<TooltipTrigger
delay={0}
render={(
<span className="ml-1 flex h-4 w-4 shrink-0 items-center justify-center">
<span aria-hidden className="i-ri-question-line h-3.5 w-3.5 text-text-quaternary hover:text-text-tertiary" />
</span>
)}
/>
<TooltipContent>
{tooltip}
</TooltipContent>
</Tooltip>
)
}
</div>

View File

@ -0,0 +1,137 @@
import type { CommonNodeType } from '../../../types'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum, NodeRunningStatus } from '../../../types'
import NodeControl from './node-control'
const {
mockHandleNodeSelect,
mockSetInitShowLastRunTab,
mockSetPendingSingleRun,
mockCanRunBySingle,
} = vi.hoisted(() => ({
mockHandleNodeSelect: vi.fn(),
mockSetInitShowLastRunTab: vi.fn(),
mockSetPendingSingleRun: vi.fn(),
mockCanRunBySingle: vi.fn(() => true),
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
vi.mock('@/app/components/base/tooltip', () => ({
default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => (
<div data-testid="tooltip" data-content={popupContent}>{children}</div>
),
}))
vi.mock('@/app/components/base/icons/src/vender/line/mediaAndDevices', () => ({
Stop: ({ className }: { className?: string }) => <div data-testid="stop-icon" className={className} />,
}))
vi.mock('../../../hooks', () => ({
useNodesInteractions: () => ({
handleNodeSelect: mockHandleNodeSelect,
}),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setInitShowLastRunTab: mockSetInitShowLastRunTab,
setPendingSingleRun: mockSetPendingSingleRun,
}),
}),
}))
vi.mock('../../../utils', () => ({
canRunBySingle: mockCanRunBySingle,
}))
vi.mock('./panel-operator', () => ({
default: ({ onOpenChange }: { onOpenChange: (open: boolean) => void }) => (
<>
<button type="button" onClick={() => onOpenChange(true)}>open panel</button>
<button type="button" onClick={() => onOpenChange(false)}>close panel</button>
</>
),
}))
const makeData = (overrides: Partial<CommonNodeType> = {}): CommonNodeType => ({
type: BlockEnum.Code,
title: 'Node',
desc: '',
selected: false,
_singleRunningStatus: undefined,
isInIteration: false,
isInLoop: false,
...overrides,
})
describe('NodeControl', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanRunBySingle.mockReturnValue(true)
})
it('should trigger a single run and show the hover control when plugins are not locked', () => {
const { container } = render(
<NodeControl
id="node-1"
data={makeData()}
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).toContain('group-hover:flex')
expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'panel.runThisStep')
fireEvent.click(screen.getByTestId('tooltip').parentElement!)
expect(mockSetInitShowLastRunTab).toHaveBeenCalledWith(true)
expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-1', action: 'run' })
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1')
})
it('should render the stop action, keep locked controls hidden by default, and stay open when panel operator opens', () => {
const { container } = render(
<NodeControl
id="node-2"
pluginInstallLocked
data={makeData({
selected: true,
_singleRunningStatus: NodeRunningStatus.Running,
isInIteration: true,
})}
/>,
)
const wrapper = container.firstChild as HTMLElement
expect(wrapper.className).not.toContain('group-hover:flex')
expect(wrapper.className).toContain('!flex')
expect(screen.getByTestId('stop-icon')).toBeInTheDocument()
fireEvent.click(screen.getByTestId('stop-icon').parentElement!)
expect(mockSetPendingSingleRun).toHaveBeenCalledWith({ nodeId: 'node-2', action: 'stop' })
fireEvent.click(screen.getByRole('button', { name: 'open panel' }))
expect(wrapper.className).toContain('!flex')
})
it('should hide the run control when single-node execution is not supported', () => {
mockCanRunBySingle.mockReturnValue(false)
render(
<NodeControl
id="node-3"
data={makeData()}
/>,
)
expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: 'open panel' })).toBeInTheDocument()
})
})

View File

@ -21,10 +21,13 @@ import { NodeRunningStatus } from '../../../types'
import { canRunBySingle } from '../../../utils'
import PanelOperator from './panel-operator'
type NodeControlProps = Pick<Node, 'id' | 'data'>
type NodeControlProps = Pick<Node, 'id' | 'data'> & {
pluginInstallLocked?: boolean
}
const NodeControl: FC<NodeControlProps> = ({
id,
data,
pluginInstallLocked,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
@ -40,7 +43,7 @@ const NodeControl: FC<NodeControlProps> = ({
<div
className={`
absolute -top-7 right-0 hidden h-7 pb-1
${!data._pluginInstallLocked && 'group-hover:flex'}
${!pluginInstallLocked && 'group-hover:flex'}
${data.selected && '!flex'}
${open && '!flex'}
`}

View File

@ -240,7 +240,7 @@ const Editor: FC<Props> = ({
<div className={cn('pb-2', isExpand && 'flex grow flex-col')}>
{!(isSupportJinja && editionType === EditionType.jinja2)
? (
<div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}>
<div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}>
<PromptEditor
key={controlPromptEditorRerenderKey}
placeholder={placeholder}
@ -278,6 +278,9 @@ const Editor: FC<Props> = ({
width: node.width,
height: node.height,
position: node.position,
...(node.data.type === BlockEnum.LLM && {
modelProvider: (node.data as { model?: ModelConfig }).model?.provider,
}),
}
if (node.data.type === BlockEnum.Start) {
acc.sys = {
@ -301,7 +304,7 @@ const Editor: FC<Props> = ({
</div>
)
: (
<div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}>
<div className={cn(isExpand ? 'grow' : 'max-h-[536px]', 'relative min-h-[56px] overflow-y-auto px-3', editorContainerClassName)}>
<CodeEditor
availableVars={nodesOutputVars || []}
varList={varList}

View File

@ -1,11 +1,8 @@
import type { VariablePayload } from '../types'
import {
RiErrorWarningFill,
RiMoreLine,
} from '@remixicon/react'
import { capitalize } from 'es-toolkit/string'
import { memo } from 'react'
import Tooltip from '@/app/components/base/tooltip'
import { Warning } from '@/app/components/base/icons/src/vender/line/alertsAndFeedback'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/app/components/base/ui/tooltip'
import { cn } from '@/utils/classnames'
import { isConversationVar, isENV, isGlobalVar, isRagVariableVar } from '../../utils'
import { useVarColor } from '../hooks'
@ -28,7 +25,8 @@ const VariableLabel = ({
}: VariablePayload) => {
const varColorClassName = useVarColor(variables, isExceptionVariable)
const isShowNodeLabel = !(isENV(variables) || isConversationVar(variables) || isGlobalVar(variables) || isRagVariableVar(variables))
return (
const badge = (
<div
className={cn(
'inline-flex h-6 max-w-full items-center space-x-0.5 rounded-md border-[0.5px] border-components-panel-border-subtle bg-components-badge-white-to-dark px-1.5 shadow-xs',
@ -47,7 +45,7 @@ const VariableLabel = ({
{
notShowFullPath && (
<>
<RiMoreLine className="h-3 w-3 shrink-0 text-text-secondary" />
<span className="i-ri-more-line h-3 w-3 shrink-0 text-text-secondary" />
<div className="shrink-0 text-divider-deep system-xs-regular">/</div>
</>
)
@ -70,12 +68,7 @@ const VariableLabel = ({
}
{
!!errorMsg && (
<Tooltip
popupContent={errorMsg}
asChild
>
<RiErrorWarningFill className="h-3 w-3 shrink-0 text-text-destructive" />
</Tooltip>
<Warning className="h-3 w-3 shrink-0 text-text-warning" />
)
}
{
@ -83,6 +76,16 @@ const VariableLabel = ({
}
</div>
)
if (!errorMsg)
return badge
return (
<Tooltip>
<TooltipTrigger render={badge} />
<TooltipContent>{errorMsg}</TooltipContent>
</Tooltip>
)
}
export default memo(VariableLabel)

View File

@ -28,8 +28,8 @@ import useQuestionClassifierSingleRunFormParams from '@/app/components/workflow/
import useStartSingleRunFormParams from '@/app/components/workflow/nodes/start/use-single-run-form-params'
import useTemplateTransformSingleRunFormParams from '@/app/components/workflow/nodes/template-transform/use-single-run-form-params'
import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/use-get-data-for-check-more'
import useToolSingleRunFormParams from '@/app/components/workflow/nodes/tool/use-single-run-form-params'
import useToolGetDataForCheckMore from '@/app/components/workflow/nodes/tool/hooks/use-get-data-for-check-more'
import useToolSingleRunFormParams from '@/app/components/workflow/nodes/tool/hooks/use-single-run-form-params'
import useTriggerPluginGetDataForCheckMore from '@/app/components/workflow/nodes/trigger-plugin/use-check-params'
import useVariableAggregatorSingleRunFormParams from '@/app/components/workflow/nodes/variable-assigner/use-single-run-form-params'
@ -159,10 +159,10 @@ const useLastRun = <T>({
if (!warningForNode)
return false
if (warningForNode.unConnected && !warningForNode.errorMessage)
if (warningForNode.unConnected && warningForNode.errorMessages.length === 0)
return false
const message = warningForNode.errorMessage || 'This node has unresolved checklist issues'
const message = warningForNode.errorMessages[0] || 'This node has unresolved checklist issues'
Toast.notify({ type: 'error', message })
return true
}, [warningNodes, id])

View File

@ -17,6 +17,7 @@ import BlockIcon from '@/app/components/workflow/block-icon'
import { ToolTypeEnum } from '@/app/components/workflow/block-selector/types'
import { useNodesReadOnly, useToolIcon } from '@/app/components/workflow/hooks'
import useInspectVarsCrud from '@/app/components/workflow/hooks/use-inspect-vars-crud'
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
import { useNodeIterationInteractions } from '@/app/components/workflow/nodes/iteration/use-interactions'
import { useNodeLoopInteractions } from '@/app/components/workflow/nodes/loop/use-interactions'
import CopyID from '@/app/components/workflow/nodes/tool/components/copy-id'
@ -61,6 +62,8 @@ const BaseNode: FC<BaseNodeProps> = ({
const { handleNodeIterationChildSizeChange } = useNodeIterationInteractions()
const { handleNodeLoopChildSizeChange } = useNodeLoopInteractions()
const toolIcon = useToolIcon(data)
const { shouldDim: pluginDimmed, isChecking: pluginIsChecking, isMissing: pluginIsMissing, canInstall: pluginCanInstall, uniqueIdentifier: pluginUniqueIdentifier } = useNodePluginInstallation(data)
const pluginInstallLocked = !pluginIsChecking && pluginIsMissing && pluginCanInstall && Boolean(pluginUniqueIdentifier)
useEffect(() => {
if (nodeRef.current && data.selected && data.isInIteration) {
@ -138,7 +141,7 @@ const BaseNode: FC<BaseNodeProps> = ({
'relative flex rounded-2xl border',
showSelectedBorder ? 'border-components-option-card-option-selected-border' : 'border-transparent',
data._waitingRun && 'opacity-70',
data._pluginInstallLocked && 'cursor-not-allowed',
pluginInstallLocked && 'cursor-not-allowed',
)}
ref={nodeRef}
style={{
@ -146,14 +149,15 @@ const BaseNode: FC<BaseNodeProps> = ({
height: (data.type === BlockEnum.Iteration || data.type === BlockEnum.Loop) ? data.height : 'auto',
}}
>
{(data._dimmed || data._pluginInstallLocked) && (
{(data._dimmed || pluginDimmed || pluginInstallLocked) && (
<div
className={cn(
'absolute inset-0 rounded-2xl transition-opacity',
data._pluginInstallLocked
pluginInstallLocked
? 'pointer-events-auto z-30 bg-workflow-block-parma-bg opacity-80 backdrop-blur-[2px]'
: 'pointer-events-none z-20 bg-workflow-block-parma-bg opacity-50',
)}
onClick={pluginInstallLocked ? e => e.stopPropagation() : undefined}
data-testid="workflow-node-install-overlay"
/>
)}
@ -229,6 +233,7 @@ const BaseNode: FC<BaseNodeProps> = ({
<NodeControl
id={id}
data={data}
pluginInstallLocked={pluginInstallLocked}
/>
)
}

View File

@ -1,13 +1,11 @@
import type { FC } from 'react'
import type { DataSourceNodeType } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
import { memo, useEffect } from 'react'
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
import { memo } from 'react'
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
const Node: FC<NodeProps<DataSourceNodeType>> = ({
id,
data,
}) => {
const {
@ -16,22 +14,7 @@ const Node: FC<NodeProps<DataSourceNodeType>> = ({
uniqueIdentifier,
canInstall,
onInstallSuccess,
shouldDim,
} = useNodePluginInstallation(data)
const { handleNodeDataUpdate } = useNodeDataUpdate()
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
useEffect(() => {
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
return
handleNodeDataUpdate({
id,
data: {
_pluginInstallLocked: shouldLock,
_dimmed: shouldDim,
},
})
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier

View File

@ -0,0 +1,77 @@
import type { ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { ChunkStructureEnum } from '../../types'
import ChunkStructure from './index'
const mockUseChunkStructure = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({
Field: ({ children, fieldTitleProps }: { children: ReactNode, fieldTitleProps: { title: string, warningDot?: boolean, operation?: ReactNode } }) => (
<div data-testid="field" data-warning-dot={String(!!fieldTitleProps.warningDot)}>
<div>{fieldTitleProps.title}</div>
{fieldTitleProps.operation}
{children}
</div>
),
}))
vi.mock('./hooks', () => ({
useChunkStructure: mockUseChunkStructure,
}))
vi.mock('../option-card', () => ({
default: ({ title }: { title: string }) => <div data-testid="option-card">{title}</div>,
}))
vi.mock('./selector', () => ({
default: ({ trigger, value }: { trigger?: ReactNode, value?: string }) => (
<div data-testid="selector">
{value ?? 'no-value'}
{trigger}
</div>
),
}))
vi.mock('./instruction', () => ({
default: ({ className }: { className?: string }) => <div data-testid="instruction" className={className}>Instruction</div>,
}))
describe('ChunkStructure', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseChunkStructure.mockReturnValue({
options: [{ value: ChunkStructureEnum.general, label: 'General' }],
optionMap: {
[ChunkStructureEnum.general]: {
title: 'General Chunk Structure',
},
},
})
})
it('should render the selected option and warning dot metadata when a chunk structure is chosen', () => {
render(
<ChunkStructure
chunkStructure={ChunkStructureEnum.general}
warningDot
onChunkStructureChange={vi.fn()}
/>,
)
expect(screen.getByTestId('field')).toHaveAttribute('data-warning-dot', 'true')
expect(screen.getByTestId('selector')).toHaveTextContent(ChunkStructureEnum.general)
expect(screen.getByTestId('option-card')).toHaveTextContent('General Chunk Structure')
expect(screen.queryByTestId('instruction')).not.toBeInTheDocument()
})
it('should render the add trigger and instruction when no chunk structure is selected', () => {
render(
<ChunkStructure
onChunkStructureChange={vi.fn()}
/>,
)
expect(screen.getByRole('button', { name: /chooseChunkStructure/i })).toBeInTheDocument()
expect(screen.getByTestId('instruction')).toBeInTheDocument()
})
})

View File

@ -1,5 +1,4 @@
import type { ChunkStructureEnum } from '../../types'
import { RiAddLine } from '@remixicon/react'
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@ -12,11 +11,13 @@ import Selector from './selector'
type ChunkStructureProps = {
chunkStructure?: ChunkStructureEnum
onChunkStructureChange: (value: ChunkStructureEnum) => void
warningDot?: boolean
readonly?: boolean
}
const ChunkStructure = ({
chunkStructure,
onChunkStructureChange,
warningDot = false,
readonly = false,
}: ChunkStructureProps) => {
const { t } = useTranslation()
@ -30,6 +31,7 @@ const ChunkStructure = ({
fieldTitleProps={{
title: t('nodes.knowledgeBase.chunkStructure', { ns: 'workflow' }),
tooltip: t('nodes.knowledgeBase.chunkStructureTip.message', { ns: 'workflow' }),
warningDot,
operation: chunkStructure && (
<Selector
options={options}
@ -62,7 +64,7 @@ const ChunkStructure = ({
className="w-full"
variant="secondary-accent"
>
<RiAddLine className="mr-1 h-4 w-4" />
<span className="i-ri-add-line mr-1 h-4 w-4" />
{t('nodes.knowledgeBase.chooseChunkStructure', { ns: 'workflow' })}
</Button>
)}

View File

@ -0,0 +1,62 @@
import type { ReactNode } from 'react'
import { render } from '@testing-library/react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import EmbeddingModel from './embedding-model'
const mockUseModelList = vi.hoisted(() => vi.fn())
const mockModelSelector = vi.hoisted(() => vi.fn(() => <div data-testid="model-selector">selector</div>))
vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({
Field: ({ children, fieldTitleProps }: { children: ReactNode, fieldTitleProps: { warningDot?: boolean } }) => (
<div data-testid="field" data-warning-dot={String(!!fieldTitleProps.warningDot)}>
{children}
</div>
),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: mockUseModelList,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: mockModelSelector,
}))
describe('EmbeddingModel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseModelList.mockReturnValue({ data: [{ provider: 'openai', model: 'text-embedding-3-large' }] })
})
it('should pass the selected model configuration and warning state to the selector field', () => {
const onEmbeddingModelChange = vi.fn()
render(
<EmbeddingModel
embeddingModel="text-embedding-3-large"
embeddingModelProvider="openai"
warningDot
onEmbeddingModelChange={onEmbeddingModelChange}
/>,
)
expect(mockUseModelList).toHaveBeenCalledWith(ModelTypeEnum.textEmbedding)
expect(mockModelSelector).toHaveBeenCalledWith(expect.objectContaining({
defaultModel: {
provider: 'openai',
model: 'text-embedding-3-large',
},
modelList: [{ provider: 'openai', model: 'text-embedding-3-large' }],
readonly: false,
showDeprecatedWarnIcon: true,
}), undefined)
})
it('should pass an undefined default model when the embedding model is incomplete', () => {
render(<EmbeddingModel embeddingModel="text-embedding-3-large" />)
expect(mockModelSelector).toHaveBeenCalledWith(expect.objectContaining({
defaultModel: undefined,
}), undefined)
})
})

View File

@ -17,12 +17,14 @@ type EmbeddingModelProps = {
embeddingModel: string
embeddingModelProvider: string
}) => void
warningDot?: boolean
readonly?: boolean
}
const EmbeddingModel = ({
embeddingModel,
embeddingModelProvider,
onEmbeddingModelChange,
warningDot = false,
readonly = false,
}: EmbeddingModelProps) => {
const { t } = useTranslation()
@ -50,6 +52,7 @@ const EmbeddingModel = ({
<Field
fieldTitleProps={{
title: t('form.embeddingModel', { ns: 'datasetSettings' }),
warningDot,
}}
>
<ModelSelector

View File

@ -0,0 +1,121 @@
import type {
DefaultModel,
Model,
ModelItem,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fireEvent, render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import RerankingModelSelector from './reranking-model-selector'
type MockModelSelectorProps = {
defaultModel?: DefaultModel
modelList: Model[]
onSelect?: (model: DefaultModel) => void
}
const mockUseModelListAndDefaultModel = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelListAndDefaultModel: mockUseModelListAndDefaultModel,
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-selector', () => ({
default: ({ defaultModel, modelList, onSelect }: MockModelSelectorProps) => (
<div>
<div data-testid="default-model">
{defaultModel ? `${defaultModel.provider}/${defaultModel.model}` : 'no-default-model'}
</div>
<div data-testid="model-list-count">{modelList.length}</div>
<button type="button" onClick={() => onSelect?.({ provider: 'cohere', model: 'rerank-v3' })}>
select-model
</button>
</div>
),
}))
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'rerank-v3',
label: { en_US: 'Rerank V3', zh_Hans: 'Rerank V3' },
model_type: ModelTypeEnum.rerank,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
})
const createModel = (overrides: Partial<Model> = {}): Model => ({
provider: 'cohere',
icon_small: {
en_US: 'https://example.com/cohere.png',
zh_Hans: 'https://example.com/cohere.png',
},
icon_small_dark: {
en_US: 'https://example.com/cohere-dark.png',
zh_Hans: 'https://example.com/cohere-dark.png',
},
label: { en_US: 'Cohere', zh_Hans: 'Cohere' },
models: [createModelItem()],
status: ModelStatusEnum.active,
...overrides,
})
describe('RerankingModelSelector', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseModelListAndDefaultModel.mockReturnValue({
modelList: [createModel()],
defaultModel: undefined,
})
})
// Rendering behavior for mapped rerank model state.
describe('Rendering', () => {
it('should not pass a default model when reranking model fields are empty strings', () => {
render(
<RerankingModelSelector
rerankingModel={{
reranking_provider_name: '',
reranking_model_name: '',
}}
/>,
)
expect(screen.getByTestId('default-model')).toHaveTextContent('no-default-model')
expect(screen.getByTestId('model-list-count')).toHaveTextContent('1')
})
it('should map reranking model to default model when both fields exist', () => {
render(
<RerankingModelSelector
rerankingModel={{
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-v3',
}}
/>,
)
expect(screen.getByTestId('default-model')).toHaveTextContent('cohere/rerank-v3')
})
})
// Selection behavior should convert back to workflow reranking model shape.
describe('Interactions', () => {
it('should map selected model back to reranking model fields', () => {
const onRerankingModelChange = vi.fn()
render(<RerankingModelSelector onRerankingModelChange={onRerankingModelChange} />)
fireEvent.click(screen.getByRole('button', { name: 'select-model' }))
expect(onRerankingModelChange).toHaveBeenCalledWith({
reranking_provider_name: 'cohere',
reranking_model_name: 'rerank-v3',
})
})
})
})

View File

@ -22,12 +22,12 @@ const RerankingModelSelector = ({
modelList: rerankModelList,
} = useModelListAndDefaultModel(ModelTypeEnum.rerank)
const rerankModel = useMemo(() => {
if (!rerankingModel)
if (!rerankingModel?.reranking_provider_name || !rerankingModel?.reranking_model_name)
return undefined
return {
providerName: rerankingModel.reranking_provider_name,
modelName: rerankingModel.reranking_model_name,
provider: rerankingModel.reranking_provider_name,
model: rerankingModel.reranking_model_name,
}
}, [rerankingModel])
@ -40,7 +40,7 @@ const RerankingModelSelector = ({
return (
<ModelSelector
defaultModel={rerankModel && { provider: rerankModel.providerName, model: rerankModel.modelName }}
defaultModel={rerankModel}
modelList={rerankModelList}
onSelect={handleRerankingModelChange}
readonly={readonly}

View File

@ -0,0 +1,74 @@
import type { KnowledgeBaseNodeType } from './types'
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import nodeDefault from './default'
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types'
const t = (key: string) => key
const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => [{
provider: 'openai',
icon_small: { en_US: '', zh_Hans: '' },
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [{
model: 'text-embedding-3-large',
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status,
model_properties: {},
load_balancing_enabled: false,
}],
status,
}]
const makeEmbeddingProviderModelList = (status: ModelStatusEnum): ModelItem[] => [{
model: 'text-embedding-3-large',
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status,
model_properties: {},
load_balancing_enabled: false,
}]
const createPayload = (overrides: Partial<KnowledgeBaseNodeType> = {}): KnowledgeBaseNodeType => ({
...nodeDefault.defaultValue,
index_chunk_variable_selector: ['chunks', 'results'],
chunk_structure: ChunkStructureEnum.general,
indexing_technique: IndexMethodEnum.QUALIFIED,
embedding_model: 'text-embedding-3-large',
embedding_model_provider: 'openai',
retrieval_model: {
...nodeDefault.defaultValue.retrieval_model,
search_method: RetrievalSearchMethodEnum.semantic,
},
_embeddingModelList: makeEmbeddingModelList(ModelStatusEnum.active),
_embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.active),
_rerankModelList: [],
...overrides,
}) as KnowledgeBaseNodeType
describe('knowledge-base default node validation', () => {
it('should return an invalid result when the payload has a validation issue', () => {
const result = nodeDefault.checkValid(createPayload({ chunk_structure: undefined }), t)
expect(result).toEqual({
isValid: false,
errorMessage: 'nodes.knowledgeBase.chunkIsRequired',
})
})
it('should return a valid result when the payload is complete', () => {
const result = nodeDefault.checkValid(createPayload(), t)
expect(result).toEqual({
isValid: true,
errorMessage: '',
})
})
})

View File

@ -1,8 +1,11 @@
import type { NodeDefault } from '../../types'
import type { KnowledgeBaseNodeType } from './types'
import { IndexingType } from '@/app/components/datasets/create/step-two'
import { BlockEnum } from '@/app/components/workflow/types'
import { genNodeMetaData } from '@/app/components/workflow/utils'
import {
getKnowledgeBaseValidationIssue,
getKnowledgeBaseValidationMessage,
} from './utils'
const metaData = genNodeMetaData({
sort: 3.1,
@ -24,86 +27,9 @@ const nodeDefault: NodeDefault<KnowledgeBaseNodeType> = {
},
},
checkValid(payload, t) {
const {
chunk_structure,
indexing_technique,
retrieval_model,
embedding_model,
embedding_model_provider,
index_chunk_variable_selector,
_embeddingModelList,
_rerankModelList,
} = payload
const {
search_method,
reranking_enable,
reranking_model,
} = retrieval_model || {}
const currentEmbeddingModelProvider = _embeddingModelList?.find(provider => provider.provider === embedding_model_provider)
const currentEmbeddingModel = currentEmbeddingModelProvider?.models.find(model => model.model === embedding_model)
const currentRerankingModelProvider = _rerankModelList?.find(provider => provider.provider === reranking_model?.reranking_provider_name)
const currentRerankingModel = currentRerankingModelProvider?.models.find(model => model.model === reranking_model?.reranking_model_name)
if (!chunk_structure) {
return {
isValid: false,
errorMessage: t('nodes.knowledgeBase.chunkIsRequired', { ns: 'workflow' }),
}
}
if (index_chunk_variable_selector.length === 0) {
return {
isValid: false,
errorMessage: t('nodes.knowledgeBase.chunksVariableIsRequired', { ns: 'workflow' }),
}
}
if (!indexing_technique) {
return {
isValid: false,
errorMessage: t('nodes.knowledgeBase.indexMethodIsRequired', { ns: 'workflow' }),
}
}
if (indexing_technique === IndexingType.QUALIFIED) {
if (!embedding_model || !embedding_model_provider) {
return {
isValid: false,
errorMessage: t('nodes.knowledgeBase.embeddingModelIsRequired', { ns: 'workflow' }),
}
}
else if (!currentEmbeddingModel) {
return {
isValid: false,
errorMessage: t('nodes.knowledgeBase.embeddingModelIsInvalid', { ns: 'workflow' }),
}
}
}
if (!retrieval_model || !search_method) {
return {
isValid: false,
errorMessage: t('nodes.knowledgeBase.retrievalSettingIsRequired', { ns: 'workflow' }),
}
}
if (reranking_enable) {
if (!reranking_model || !reranking_model.reranking_provider_name || !reranking_model.reranking_model_name) {
return {
isValid: false,
errorMessage: t('nodes.knowledgeBase.rerankingModelIsRequired', { ns: 'workflow' }),
}
}
else if (!currentRerankingModel) {
return {
isValid: false,
errorMessage: t('nodes.knowledgeBase.rerankingModelIsInvalid', { ns: 'workflow' }),
}
}
}
const issue = getKnowledgeBaseValidationIssue(payload)
if (issue)
return { isValid: false, errorMessage: getKnowledgeBaseValidationMessage(issue, t) }
return {
isValid: true,

View File

@ -0,0 +1,61 @@
import type {
Model,
ModelItem,
ModelProvider,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useMemo } from 'react'
import { deriveModelStatus } from '@/app/components/header/account-setting/model-provider-page/derive-model-status'
import { useCredentialPanelState } from '@/app/components/header/account-setting/model-provider-page/provider-added-card/use-credential-panel-state'
import { useProviderContext } from '@/context/provider-context'
type UseEmbeddingModelStatusProps = {
embeddingModel?: string
embeddingModelProvider?: string
embeddingModelList: Model[]
}
type UseEmbeddingModelStatusResult = {
providerMeta: ModelProvider | undefined
modelProvider: Model | undefined
currentModel: ModelItem | undefined
status: ReturnType<typeof deriveModelStatus>
}
export const useEmbeddingModelStatus = ({
embeddingModel,
embeddingModelProvider,
embeddingModelList,
}: UseEmbeddingModelStatusProps): UseEmbeddingModelStatusResult => {
const { modelProviders } = useProviderContext()
const providerMeta = useMemo(() => {
return modelProviders.find(provider => provider.provider === embeddingModelProvider)
}, [embeddingModelProvider, modelProviders])
const modelProvider = useMemo(() => {
return embeddingModelList.find(provider => provider.provider === embeddingModelProvider)
}, [embeddingModelList, embeddingModelProvider])
const currentModel = useMemo(() => {
return modelProvider?.models.find(model => model.model === embeddingModel)
}, [embeddingModel, modelProvider])
const credentialState = useCredentialPanelState(providerMeta)
const status = useMemo(() => {
return deriveModelStatus(
embeddingModel,
embeddingModelProvider,
providerMeta,
currentModel,
credentialState,
)
}, [credentialState, currentModel, embeddingModel, embeddingModelProvider, providerMeta])
return {
providerMeta,
modelProvider,
currentModel,
status,
}
}

View File

@ -0,0 +1,233 @@
import type { KnowledgeBaseNodeType } from './types'
import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CommonNodeType } from '@/app/components/workflow/types'
import { render, screen } from '@testing-library/react'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { BlockEnum } from '@/app/components/workflow/types'
import Node from './node'
import {
ChunkStructureEnum,
IndexMethodEnum,
RetrievalSearchMethodEnum,
} from './types'
const mockUseModelList = vi.hoisted(() => vi.fn())
const mockUseSettingsDisplay = vi.hoisted(() => vi.fn())
const mockUseEmbeddingModelStatus = vi.hoisted(() => vi.fn())
vi.mock('@tanstack/react-query', async (importOriginal) => {
const actual = await importOriginal<typeof import('@tanstack/react-query')>()
return {
...actual,
useQuery: () => ({ data: undefined }),
}
})
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/app/components/header/account-setting/model-provider-page/hooks')>()
return {
...actual,
useLanguage: () => 'en_US',
useModelList: mockUseModelList,
}
})
vi.mock('./hooks/use-settings-display', () => ({
useSettingsDisplay: mockUseSettingsDisplay,
}))
vi.mock('./hooks/use-embedding-model-status', () => ({
useEmbeddingModelStatus: mockUseEmbeddingModelStatus,
}))
const createModelItem = (overrides: Partial<ModelItem> = {}): ModelItem => ({
model: 'text-embedding-3-large',
label: { en_US: 'Text Embedding 3 Large', zh_Hans: 'Text Embedding 3 Large' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status: ModelStatusEnum.active,
model_properties: {},
load_balancing_enabled: false,
...overrides,
})
const createNodeData = (overrides: Partial<CommonNodeType<KnowledgeBaseNodeType>> = {}): CommonNodeType<KnowledgeBaseNodeType> => ({
title: 'Knowledge Base',
desc: '',
type: BlockEnum.KnowledgeBase,
index_chunk_variable_selector: ['result'],
chunk_structure: ChunkStructureEnum.general,
indexing_technique: IndexMethodEnum.QUALIFIED,
embedding_model: 'text-embedding-3-large',
embedding_model_provider: 'openai',
keyword_number: 10,
retrieval_model: {
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
search_method: RetrievalSearchMethodEnum.semantic,
},
...overrides,
})
describe('KnowledgeBaseNode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseModelList.mockReturnValue({ data: [] })
mockUseSettingsDisplay.mockReturnValue({
[IndexMethodEnum.QUALIFIED]: 'High Quality',
[RetrievalSearchMethodEnum.semantic]: 'Vector Search',
})
mockUseEmbeddingModelStatus.mockReturnValue({
providerMeta: undefined,
modelProvider: undefined,
currentModel: createModelItem(),
status: 'active',
})
})
// Embedding model row should mirror the selector status labels.
describe('Embedding Model Status', () => {
it('should render active embedding model label when the model is available', () => {
render(<Node id="knowledge-base-1" data={createNodeData()} />)
expect(screen.getByText('Text Embedding 3 Large')).toBeInTheDocument()
})
it('should render configure required when embedding model status requires configuration', () => {
mockUseEmbeddingModelStatus.mockReturnValue({
providerMeta: undefined,
modelProvider: undefined,
currentModel: createModelItem({ status: ModelStatusEnum.noConfigure }),
status: 'configure-required',
})
render(<Node id="knowledge-base-1" data={createNodeData()} />)
expect(screen.getByText('common.modelProvider.selector.configureRequired')).toBeInTheDocument()
})
it('should render disabled when embedding model status is disabled', () => {
mockUseEmbeddingModelStatus.mockReturnValue({
providerMeta: undefined,
modelProvider: undefined,
currentModel: createModelItem({ status: ModelStatusEnum.disabled }),
status: 'disabled',
})
render(<Node id="knowledge-base-1" data={createNodeData()} />)
expect(screen.getByText('common.modelProvider.selector.disabled')).toBeInTheDocument()
})
it('should render incompatible when embedding model status is incompatible', () => {
mockUseEmbeddingModelStatus.mockReturnValue({
providerMeta: undefined,
modelProvider: undefined,
currentModel: undefined,
status: 'incompatible',
})
render(<Node id="knowledge-base-1" data={createNodeData()} />)
expect(screen.getByText('common.modelProvider.selector.incompatible')).toBeInTheDocument()
})
it('should render configure model prompt when no embedding model is selected', () => {
mockUseEmbeddingModelStatus.mockReturnValue({
providerMeta: undefined,
modelProvider: undefined,
currentModel: undefined,
status: 'empty',
})
render(
<Node
id="knowledge-base-1"
data={createNodeData({
embedding_model: undefined,
embedding_model_provider: undefined,
})}
/>,
)
expect(screen.getByText('plugin.detailPanel.configureModel')).toBeInTheDocument()
})
})
describe('Validation warnings', () => {
it('should render a warning banner when chunk structure is missing', () => {
render(
<Node
id="knowledge-base-1"
data={createNodeData({
chunk_structure: undefined,
})}
/>,
)
expect(screen.getByText(/chunkIsRequired/i)).toBeInTheDocument()
})
it('should render a warning value for the chunks input row when no chunk variable is selected', () => {
render(
<Node
id="knowledge-base-1"
data={createNodeData({
index_chunk_variable_selector: [],
})}
/>,
)
expect(screen.getByText(/chunksVariableIsRequired/i)).toBeInTheDocument()
})
it('should render a warning value for retrieval settings when reranking is incomplete', () => {
mockUseModelList.mockImplementation((modelType: ModelTypeEnum) => {
if (modelType === ModelTypeEnum.textEmbedding) {
return {
data: [{
provider: 'openai',
models: [createModelItem()],
}],
}
}
return { data: [] }
})
render(
<Node
id="knowledge-base-1"
data={createNodeData({
retrieval_model: {
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
search_method: RetrievalSearchMethodEnum.semantic,
reranking_enable: true,
},
})}
/>,
)
expect(screen.getByText(/rerankingModelIsRequired/i)).toBeInTheDocument()
})
it('should hide the embedding model row when the index method is not qualified', () => {
render(
<Node
id="knowledge-base-1"
data={createNodeData({
indexing_technique: IndexMethodEnum.ECONOMICAL,
})}
/>,
)
expect(screen.queryByText('Text Embedding 3 Large')).not.toBeInTheDocument()
})
})
})

View File

@ -1,38 +1,210 @@
import type { FC } from 'react'
import type { KnowledgeBaseNodeType } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
import { memo } from 'react'
import { useQuery } from '@tanstack/react-query'
import {
memo,
useMemo,
} from 'react'
import { useTranslation } from 'react-i18next'
import {
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { DERIVED_MODEL_STATUS_BADGE_I18N } from '@/app/components/header/account-setting/model-provider-page/derive-model-status'
import {
useLanguage,
useModelList,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import { consoleQuery } from '@/service/client'
import { cn } from '@/utils/classnames'
import { useEmbeddingModelStatus } from './hooks/use-embedding-model-status'
import { useSettingsDisplay } from './hooks/use-settings-display'
import {
IndexMethodEnum,
} from './types'
import {
getKnowledgeBaseValidationIssue,
getKnowledgeBaseValidationMessage,
KnowledgeBaseValidationIssueCode,
} from './utils'
type SettingRowProps = {
label: string
value: string
warning?: boolean
}
const SettingRow = memo(({
label,
value,
warning = false,
}: SettingRowProps) => {
return (
<div
className={cn(
'flex h-6 items-center rounded-md px-1.5',
warning
? 'border-[0.5px] border-state-warning-active bg-state-warning-hover'
: 'bg-workflow-block-parma-bg',
)}
>
<div className="mr-2 shrink-0 text-text-tertiary system-xs-medium-uppercase">
{label}
</div>
<div
className={cn('grow truncate text-right system-xs-medium', warning ? 'text-text-warning' : 'text-text-secondary')}
title={value}
>
{value}
</div>
</div>
)
})
const RETRIEVAL_WARNING_CODES = new Set<KnowledgeBaseValidationIssueCode>([
KnowledgeBaseValidationIssueCode.retrievalSettingRequired,
KnowledgeBaseValidationIssueCode.rerankingModelRequired,
KnowledgeBaseValidationIssueCode.rerankingModelInvalid,
])
const Node: FC<NodeProps<KnowledgeBaseNodeType>> = ({ data }) => {
const { t } = useTranslation()
const language = useLanguage()
const settingsDisplay = useSettingsDisplay()
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const chunkStructure = data.chunk_structure
const indexChunkVariableSelector = data.index_chunk_variable_selector
const indexingTechnique = data.indexing_technique
const embeddingModel = data.embedding_model
const retrievalModel = data.retrieval_model
const retrievalSearchMethod = retrievalModel?.search_method
const retrievalRerankingEnable = retrievalModel?.reranking_enable
const embeddingModelProvider = data.embedding_model_provider
const { data: embeddingProviderModelList } = useQuery(
consoleQuery.modelProviders.models.queryOptions({
input: { params: { provider: embeddingModelProvider || '' } },
enabled: indexingTechnique === IndexMethodEnum.QUALIFIED && !!embeddingModelProvider,
refetchOnWindowFocus: false,
select: response => response.data,
}),
)
const validationPayload = useMemo(() => {
return {
chunk_structure: chunkStructure,
index_chunk_variable_selector: indexChunkVariableSelector,
indexing_technique: indexingTechnique,
embedding_model: embeddingModel,
embedding_model_provider: embeddingModelProvider,
retrieval_model: {
search_method: retrievalSearchMethod,
reranking_enable: retrievalRerankingEnable,
reranking_model: retrievalModel?.reranking_model,
},
_embeddingModelList: embeddingModelList,
_embeddingProviderModelList: embeddingProviderModelList,
_rerankModelList: rerankModelList,
}
}, [
chunkStructure,
indexChunkVariableSelector,
indexingTechnique,
embeddingModel,
embeddingModelProvider,
retrievalSearchMethod,
retrievalRerankingEnable,
retrievalModel?.reranking_model,
embeddingModelList,
embeddingProviderModelList,
rerankModelList,
])
const validationIssue = useMemo(() => {
return getKnowledgeBaseValidationIssue({
...validationPayload,
})
}, [validationPayload])
const validationIssueMessage = useMemo(() => {
return getKnowledgeBaseValidationMessage(validationIssue, t)
}, [validationIssue, t])
const { currentModel: currentEmbeddingModel, status: embeddingModelStatus } = useEmbeddingModelStatus({
embeddingModel: data.embedding_model,
embeddingModelProvider: data.embedding_model_provider,
embeddingModelList,
})
const chunksDisplayValue = useMemo(() => {
if (!data.index_chunk_variable_selector?.length)
return '-'
const chunkVar = data.index_chunk_variable_selector.at(-1)
return chunkVar || '-'
}, [data.index_chunk_variable_selector])
const embeddingModelDisplay = useMemo(() => {
if (data.indexing_technique !== IndexMethodEnum.QUALIFIED)
return '-'
if (embeddingModelStatus === 'empty')
return t('detailPanel.configureModel', { ns: 'plugin' })
if (embeddingModelStatus !== 'active') {
const statusI18nKey = DERIVED_MODEL_STATUS_BADGE_I18N[embeddingModelStatus as keyof typeof DERIVED_MODEL_STATUS_BADGE_I18N]
if (statusI18nKey)
return t(statusI18nKey as 'modelProvider.selector.incompatible', { ns: 'common' })
}
return currentEmbeddingModel?.label[language] || currentEmbeddingModel?.label.en_US || data.embedding_model || '-'
}, [currentEmbeddingModel, data.embedding_model, data.indexing_technique, embeddingModelStatus, language, t])
const indexMethodDisplay = settingsDisplay[data.indexing_technique as keyof typeof settingsDisplay] || '-'
const retrievalMethodDisplay = settingsDisplay[data.retrieval_model?.search_method as keyof typeof settingsDisplay] || '-'
const chunksWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.chunksVariableRequired
const indexMethodWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.indexMethodRequired
const embeddingWarning = data.indexing_technique === IndexMethodEnum.QUALIFIED && embeddingModelStatus !== 'active'
const showEmbeddingModelRow = data.indexing_technique === IndexMethodEnum.QUALIFIED
const retrievalWarning = !!(validationIssue && RETRIEVAL_WARNING_CODES.has(validationIssue.code))
if (!data.chunk_structure) {
return (
<div className="mb-1 space-y-0.5 px-3 py-1">
<div className="flex h-6 items-center rounded-md border-[0.5px] border-state-warning-active bg-state-warning-hover px-1.5">
<span className="mr-1 size-[4px] shrink-0 rounded-[2px] bg-text-warning-secondary" />
<div className="grow truncate text-text-warning system-xs-medium" title={validationIssueMessage}>
{validationIssueMessage}
</div>
</div>
</div>
)
}
return (
<div className="mb-1 space-y-0.5 px-3 py-1">
<div className="flex h-6 items-center rounded-md bg-workflow-block-parma-bg px-1.5">
<div className="system-xs-medium-uppercase mr-2 shrink-0 text-text-tertiary">
{t('stepTwo.indexMode', { ns: 'datasetCreation' })}
</div>
<div
className="system-xs-medium grow truncate text-right text-text-secondary"
title={data.indexing_technique}
>
{settingsDisplay[data.indexing_technique as keyof typeof settingsDisplay]}
</div>
</div>
<div className="flex h-6 items-center rounded-md bg-workflow-block-parma-bg px-1.5">
<div className="system-xs-medium-uppercase mr-2 shrink-0 text-text-tertiary">
{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}
</div>
<div
className="system-xs-medium grow truncate text-right text-text-secondary"
title={data.retrieval_model?.search_method}
>
{settingsDisplay[data.retrieval_model?.search_method as keyof typeof settingsDisplay]}
</div>
</div>
<SettingRow
label={t('nodes.knowledgeBase.chunksInput', { ns: 'workflow' })}
value={chunksWarning ? validationIssueMessage : chunksDisplayValue}
warning={chunksWarning}
/>
<SettingRow
label={t('stepTwo.indexMode', { ns: 'datasetCreation' })}
value={indexMethodWarning ? validationIssueMessage : indexMethodDisplay}
warning={indexMethodWarning}
/>
{showEmbeddingModelRow && (
<SettingRow
label={t('form.embeddingModel', { ns: 'datasetSettings' })}
value={embeddingModelDisplay}
warning={embeddingWarning}
/>
)}
<SettingRow
label={t('form.retrievalSetting.method', { ns: 'datasetSettings' })}
value={retrievalWarning ? validationIssueMessage : retrievalMethodDisplay}
warning={retrievalWarning}
/>
</div>
)
}

View File

@ -0,0 +1,198 @@
import type { ReactNode } from 'react'
import type { PanelProps } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import Panel from './panel'
import { ChunkStructureEnum, IndexMethodEnum, RetrievalSearchMethodEnum } from './types'
const mockUseModelList = vi.hoisted(() => vi.fn())
const mockUseQuery = vi.hoisted(() => vi.fn())
const mockUseEmbeddingModelStatus = vi.hoisted(() => vi.fn())
const mockChunkStructure = vi.hoisted(() => vi.fn(() => <div data-testid="chunk-structure" />))
const mockEmbeddingModel = vi.hoisted(() => vi.fn(() => <div data-testid="embedding-model" />))
const mockSummaryIndexSetting = vi.hoisted(() => vi.fn(() => <div data-testid="summary-index-setting" />))
const mockQueryOptions = vi.hoisted(() => vi.fn((options: unknown) => options))
vi.mock('@tanstack/react-query', () => ({
useQuery: mockUseQuery,
}))
vi.mock('@/service/client', () => ({
consoleQuery: {
modelProviders: {
models: {
queryOptions: mockQueryOptions,
},
},
},
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useModelList: mockUseModelList,
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
}))
vi.mock('./hooks/use-config', () => ({
useConfig: () => ({
handleChunkStructureChange: vi.fn(),
handleIndexMethodChange: vi.fn(),
handleKeywordNumberChange: vi.fn(),
handleEmbeddingModelChange: vi.fn(),
handleRetrievalSearchMethodChange: vi.fn(),
handleHybridSearchModeChange: vi.fn(),
handleRerankingModelEnabledChange: vi.fn(),
handleWeighedScoreChange: vi.fn(),
handleRerankingModelChange: vi.fn(),
handleTopKChange: vi.fn(),
handleScoreThresholdChange: vi.fn(),
handleScoreThresholdEnabledChange: vi.fn(),
handleInputVariableChange: vi.fn(),
handleSummaryIndexSettingChange: vi.fn(),
}),
}))
vi.mock('./hooks/use-embedding-model-status', () => ({
useEmbeddingModelStatus: mockUseEmbeddingModelStatus,
}))
vi.mock('@/app/components/datasets/settings/utils', () => ({
checkShowMultiModalTip: () => false,
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
IS_CE_EDITION: true,
}
})
vi.mock('@/app/components/workflow/nodes/_base/components/layout', () => ({
Group: ({ children }: { children: ReactNode }) => <div data-testid="group">{children}</div>,
BoxGroup: ({ children }: { children: ReactNode }) => <div data-testid="box-group">{children}</div>,
BoxGroupField: ({ children, fieldProps }: { children: ReactNode, fieldProps: { fieldTitleProps: { warningDot?: boolean } } }) => (
<div data-testid="box-group-field" data-warning-dot={String(!!fieldProps.fieldTitleProps.warningDot)}>
{children}
</div>
),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({
default: () => <div data-testid="var-reference-picker" />,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({
default: () => <div data-testid="split" />,
}))
vi.mock('@/app/components/datasets/settings/summary-index-setting', () => ({
default: mockSummaryIndexSetting,
}))
vi.mock('./components/chunk-structure', () => ({
default: mockChunkStructure,
}))
vi.mock('./components/index-method', () => ({
default: () => <div data-testid="index-method" />,
}))
vi.mock('./components/embedding-model', () => ({
default: mockEmbeddingModel,
}))
vi.mock('./components/retrieval-setting', () => ({
default: () => <div data-testid="retrieval-setting" />,
}))
const createData = (overrides: Record<string, unknown> = {}) => ({
index_chunk_variable_selector: ['chunks', 'results'],
chunk_structure: ChunkStructureEnum.general,
indexing_technique: IndexMethodEnum.QUALIFIED,
embedding_model: 'text-embedding-3-large',
embedding_model_provider: 'openai',
keyword_number: 10,
retrieval_model: {
search_method: RetrievalSearchMethodEnum.semantic,
reranking_enable: false,
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
},
...overrides,
})
const panelProps: PanelProps = {
getInputVars: () => [],
toVarInputs: () => [],
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: undefined,
}
describe('KnowledgeBasePanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseQuery.mockReturnValue({ data: undefined })
mockUseModelList.mockImplementation((modelType: ModelTypeEnum) => {
if (modelType === ModelTypeEnum.textEmbedding) {
return {
data: [{
provider: 'openai',
models: [{ model: 'text-embedding-3-large' }],
}],
}
}
return { data: [] }
})
mockUseEmbeddingModelStatus.mockReturnValue({ status: 'active' })
})
it('should show a warning dot on chunk structure and skip nested sections when chunk structure is missing', () => {
render(<Panel id="knowledge-base-1" data={createData({ chunk_structure: undefined }) as never} panelProps={panelProps} />)
expect(mockChunkStructure).toHaveBeenCalledWith(expect.objectContaining({
warningDot: true,
}), undefined)
expect(screen.queryByTestId('box-group-field')).not.toBeInTheDocument()
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
enabled: true,
}))
})
it('should pass warning dots and render summary settings when the qualified configuration needs attention', () => {
mockUseEmbeddingModelStatus.mockReturnValue({ status: 'disabled' })
render(<Panel id="knowledge-base-1" data={createData({ index_chunk_variable_selector: [] }) as never} panelProps={panelProps} />)
expect(screen.getByTestId('box-group-field')).toHaveAttribute('data-warning-dot', 'true')
expect(mockEmbeddingModel).toHaveBeenCalledWith(expect.objectContaining({
warningDot: true,
}), undefined)
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
input: { params: { provider: 'openai' } },
enabled: true,
}))
expect(screen.getByTestId('summary-index-setting')).toBeInTheDocument()
})
it('should hide embedding and summary settings for non-qualified index methods', () => {
render(
<Panel
id="knowledge-base-1"
data={createData({ indexing_technique: IndexMethodEnum.ECONOMICAL }) as never}
panelProps={panelProps}
/>,
)
expect(screen.queryByTestId('embedding-model')).not.toBeInTheDocument()
expect(screen.queryByTestId('summary-index-setting')).not.toBeInTheDocument()
expect(mockQueryOptions).toHaveBeenCalledWith(expect.objectContaining({
enabled: false,
}))
})
})

View File

@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { KnowledgeBaseNodeType } from './types'
import type { NodePanelProps, Var } from '@/app/components/workflow/types'
import { useQuery } from '@tanstack/react-query'
import {
memo,
useCallback,
@ -19,16 +20,22 @@ import {
} from '@/app/components/workflow/nodes/_base/components/layout'
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
import { IS_CE_EDITION } from '@/config'
import { consoleQuery } from '@/service/client'
import Split from '../_base/components/split'
import ChunkStructure from './components/chunk-structure'
import EmbeddingModel from './components/embedding-model'
import IndexMethod from './components/index-method'
import RetrievalSetting from './components/retrieval-setting'
import { useConfig } from './hooks/use-config'
import { useEmbeddingModelStatus } from './hooks/use-embedding-model-status'
import {
ChunkStructureEnum,
IndexMethodEnum,
} from './types'
import {
getKnowledgeBaseValidationIssue,
KnowledgeBaseValidationIssueCode,
} from './utils'
const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
id,
@ -38,6 +45,22 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
const { nodesReadOnly } = useNodesReadOnly()
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
const chunkStructure = data.chunk_structure
const indexChunkVariableSelector = data.index_chunk_variable_selector
const indexingTechnique = data.indexing_technique
const embeddingModel = data.embedding_model
const retrievalModel = data.retrieval_model
const retrievalSearchMethod = retrievalModel?.search_method
const retrievalRerankingEnable = retrievalModel?.reranking_enable
const embeddingModelProvider = data.embedding_model_provider
const { data: embeddingProviderModelList } = useQuery(
consoleQuery.modelProviders.models.queryOptions({
input: { params: { provider: embeddingModelProvider || '' } },
enabled: indexingTechnique === IndexMethodEnum.QUALIFIED && !!embeddingModelProvider,
refetchOnWindowFocus: false,
select: response => response.data,
}),
)
const {
handleChunkStructureChange,
@ -108,6 +131,49 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
})
}, [data.embedding_model_provider, data.embedding_model, data.retrieval_model?.reranking_enable, data.retrieval_model?.reranking_model, data.indexing_technique, embeddingModelList, rerankModelList])
const validationPayload = useMemo(() => {
return {
chunk_structure: chunkStructure,
index_chunk_variable_selector: indexChunkVariableSelector,
indexing_technique: indexingTechnique,
embedding_model: embeddingModel,
embedding_model_provider: embeddingModelProvider,
retrieval_model: {
search_method: retrievalSearchMethod,
reranking_enable: retrievalRerankingEnable,
reranking_model: retrievalModel?.reranking_model,
},
_embeddingModelList: embeddingModelList,
_embeddingProviderModelList: embeddingProviderModelList,
_rerankModelList: rerankModelList,
}
}, [
chunkStructure,
indexChunkVariableSelector,
indexingTechnique,
embeddingModel,
embeddingModelProvider,
retrievalSearchMethod,
retrievalRerankingEnable,
retrievalModel?.reranking_model,
embeddingModelList,
embeddingProviderModelList,
rerankModelList,
])
const validationIssue = useMemo(() => {
return getKnowledgeBaseValidationIssue(validationPayload)
}, [validationPayload])
const { status: embeddingModelStatus } = useEmbeddingModelStatus({
embeddingModel,
embeddingModelProvider,
embeddingModelList,
})
const chunkStructureWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.chunkStructureRequired
const chunksInputWarning = validationIssue?.code === KnowledgeBaseValidationIssueCode.chunksVariableRequired
const embeddingModelWarning = indexingTechnique === IndexMethodEnum.QUALIFIED && embeddingModelStatus !== 'active'
return (
<div>
<Group
@ -117,6 +183,7 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
<ChunkStructure
chunkStructure={data.chunk_structure}
onChunkStructureChange={handleChunkStructureChange}
warningDot={chunkStructureWarning}
readonly={nodesReadOnly}
/>
</Group>
@ -131,6 +198,7 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
fieldTitleProps: {
title: t('nodes.knowledgeBase.chunksInput', { ns: 'workflow' }),
tooltip: t('nodes.knowledgeBase.chunksInputTip', { ns: 'workflow' }),
warningDot: chunksInputWarning,
},
}}
>
@ -163,6 +231,7 @@ const Panel: FC<NodePanelProps<KnowledgeBaseNodeType>> = ({
embeddingModel={data.embedding_model}
embeddingModelProvider={data.embedding_model_provider}
onEmbeddingModelChange={handleEmbeddingModelChange}
warningDot={embeddingModelWarning}
readonly={nodesReadOnly}
/>
)

View File

@ -1,5 +1,5 @@
import type { IndexingType } from '@/app/components/datasets/create/step-two'
import type { Model } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { CommonNodeType } from '@/app/components/workflow/types'
import type { RerankingModeEnum, WeightedScoreEnum } from '@/models/datasets'
import type { RETRIEVE_METHOD } from '@/types/app'
@ -57,6 +57,7 @@ export type KnowledgeBaseNodeType = CommonNodeType & {
keyword_number: number
retrieval_model: RetrievalSetting
_embeddingModelList?: Model[]
_embeddingProviderModelList?: ModelItem[]
_rerankModelList?: Model[]
summary_index_setting?: SummaryIndexSetting
}

View File

@ -0,0 +1,226 @@
import type { KnowledgeBaseNodeType } from './types'
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
ConfigurationMethodEnum,
ModelStatusEnum,
ModelTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
ChunkStructureEnum,
IndexMethodEnum,
RetrievalSearchMethodEnum,
} from './types'
import {
getKnowledgeBaseValidationIssue,
getKnowledgeBaseValidationMessage,
isHighQualitySearchMethod,
isKnowledgeBaseEmbeddingIssue,
KnowledgeBaseValidationIssueCode,
} from './utils'
const makeEmbeddingModelList = (status: ModelStatusEnum): Model[] => {
return [
{
provider: 'openai',
icon_small: { en_US: '', zh_Hans: '' },
label: { en_US: 'OpenAI', zh_Hans: 'OpenAI' },
models: [{
model: 'gpt-4o',
label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status,
model_properties: {},
load_balancing_enabled: false,
}],
status,
},
]
}
const makeEmbeddingProviderModelList = (status: ModelStatusEnum): ModelItem[] => {
return [{
model: 'gpt-4o',
label: { en_US: 'GPT-4o', zh_Hans: 'GPT-4o' },
model_type: ModelTypeEnum.textEmbedding,
fetch_from: ConfigurationMethodEnum.predefinedModel,
status,
model_properties: {},
load_balancing_enabled: false,
}]
}
const makePayload = (overrides: Partial<KnowledgeBaseNodeType> = {}): KnowledgeBaseNodeType => {
return {
index_chunk_variable_selector: ['general_chunks', 'results'],
chunk_structure: ChunkStructureEnum.general,
indexing_technique: IndexMethodEnum.QUALIFIED,
embedding_model: 'gpt-4o',
embedding_model_provider: 'openai',
keyword_number: 10,
retrieval_model: {
top_k: 3,
score_threshold_enabled: false,
score_threshold: 0.5,
search_method: RetrievalSearchMethodEnum.semantic,
},
_embeddingModelList: makeEmbeddingModelList(ModelStatusEnum.active),
_embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.active),
_rerankModelList: [],
...overrides,
} as KnowledgeBaseNodeType
}
describe('knowledge-base validation issue', () => {
it('identifies high quality retrieval methods', () => {
expect(isHighQualitySearchMethod(RetrievalSearchMethodEnum.semantic)).toBe(true)
expect(isHighQualitySearchMethod(RetrievalSearchMethodEnum.hybrid)).toBe(true)
expect(isHighQualitySearchMethod(RetrievalSearchMethodEnum.fullText)).toBe(true)
expect(isHighQualitySearchMethod('unknown-method' as RetrievalSearchMethodEnum)).toBe(false)
})
it('returns chunk structure issue when chunk structure is missing', () => {
const issue = getKnowledgeBaseValidationIssue(makePayload({ chunk_structure: undefined }))
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.chunkStructureRequired)
})
it('returns chunks variable issue when chunks selector is empty', () => {
const issue = getKnowledgeBaseValidationIssue(makePayload({ index_chunk_variable_selector: [] }))
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.chunksVariableRequired)
})
it('maps no-configure to configure required', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.noConfigure) }),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired)
})
it('maps credential-removed to API key unavailable', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.credentialRemoved) }),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable)
})
it('maps quota-exceeded to credits exhausted', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.quotaExceeded) }),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted)
})
it('maps disabled to disabled', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.disabled) }),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelDisabled)
})
it('maps missing provider plugin to incompatible when embedding model is already configured', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({
embedding_model_provider: 'missing-provider',
_embeddingProviderModelList: undefined,
}),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible)
})
it('falls back to provider model list when provider scoped model list is empty', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ _embeddingProviderModelList: [] }),
)
expect(issue).toBeNull()
})
it('returns embedding-model-not-configured when the qualified index is missing provider details', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ embedding_model: undefined }),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured)
})
it('maps no-permission embedding models to incompatible', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ _embeddingProviderModelList: makeEmbeddingProviderModelList(ModelStatusEnum.noPermission) }),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible)
})
it('returns retrieval-setting-required when retrieval search method is missing', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({ retrieval_model: undefined as never }),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.retrievalSettingRequired)
})
it('returns reranking-model-required when reranking is enabled without a model', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({
retrieval_model: {
...makePayload().retrieval_model,
reranking_enable: true,
},
}),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.rerankingModelRequired)
})
it('returns reranking-model-invalid when the configured reranking model is unavailable', () => {
const issue = getKnowledgeBaseValidationIssue(
makePayload({
retrieval_model: {
...makePayload().retrieval_model,
reranking_enable: true,
reranking_model: {
reranking_provider_name: 'missing-provider',
reranking_model_name: 'missing-model',
},
},
}),
)
expect(issue?.code).toBe(KnowledgeBaseValidationIssueCode.rerankingModelInvalid)
})
})
describe('knowledge-base validation messaging', () => {
const t = (key: string) => key
it.each([
[KnowledgeBaseValidationIssueCode.chunkStructureRequired, 'nodes.knowledgeBase.chunkIsRequired'],
[KnowledgeBaseValidationIssueCode.chunksVariableRequired, 'nodes.knowledgeBase.chunksVariableIsRequired'],
[KnowledgeBaseValidationIssueCode.indexMethodRequired, 'nodes.knowledgeBase.indexMethodIsRequired'],
[KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured, 'nodes.knowledgeBase.embeddingModelNotConfigured'],
[KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired, 'modelProvider.selector.configureRequired'],
[KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable, 'modelProvider.selector.apiKeyUnavailable'],
[KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted, 'modelProvider.selector.creditsExhausted'],
[KnowledgeBaseValidationIssueCode.embeddingModelDisabled, 'modelProvider.selector.disabled'],
[KnowledgeBaseValidationIssueCode.embeddingModelIncompatible, 'modelProvider.selector.incompatible'],
[KnowledgeBaseValidationIssueCode.retrievalSettingRequired, 'nodes.knowledgeBase.retrievalSettingIsRequired'],
[KnowledgeBaseValidationIssueCode.rerankingModelRequired, 'nodes.knowledgeBase.rerankingModelIsRequired'],
[KnowledgeBaseValidationIssueCode.rerankingModelInvalid, 'nodes.knowledgeBase.rerankingModelIsInvalid'],
] as const)('maps %s to the expected translation key', (code, expectedKey) => {
expect(getKnowledgeBaseValidationMessage({ code }, t as never)).toBe(expectedKey)
})
it('returns an empty string when there is no issue', () => {
expect(getKnowledgeBaseValidationMessage(undefined, t as never)).toBe('')
})
})
describe('isKnowledgeBaseEmbeddingIssue', () => {
it('returns true for embedding-related issues', () => {
expect(isKnowledgeBaseEmbeddingIssue({ code: KnowledgeBaseValidationIssueCode.embeddingModelDisabled })).toBe(true)
})
it('returns false for non-embedding issues and missing values', () => {
expect(isKnowledgeBaseEmbeddingIssue({ code: KnowledgeBaseValidationIssueCode.rerankingModelInvalid })).toBe(false)
expect(isKnowledgeBaseEmbeddingIssue(undefined)).toBe(false)
})
})

View File

@ -1,3 +1,11 @@
import type { TFunction } from 'i18next'
import type { KnowledgeBaseNodeType } from './types'
import {
IndexingType,
} from '@/app/components/datasets/create/step-two'
import {
ModelStatusEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import {
RetrievalSearchMethodEnum,
} from './types'
@ -7,3 +15,174 @@ export const isHighQualitySearchMethod = (searchMethod: RetrievalSearchMethodEnu
|| searchMethod === RetrievalSearchMethodEnum.hybrid
|| searchMethod === RetrievalSearchMethodEnum.fullText
}
export enum KnowledgeBaseValidationIssueCode {
chunkStructureRequired = 'chunk-structure-required',
chunksVariableRequired = 'chunks-variable-required',
indexMethodRequired = 'index-method-required',
embeddingModelNotConfigured = 'embedding-model-not-configured',
embeddingModelConfigureRequired = 'embedding-model-configure-required',
embeddingModelApiKeyUnavailable = 'embedding-model-api-key-unavailable',
embeddingModelCreditsExhausted = 'embedding-model-credits-exhausted',
embeddingModelDisabled = 'embedding-model-disabled',
embeddingModelIncompatible = 'embedding-model-incompatible',
retrievalSettingRequired = 'retrieval-setting-required',
rerankingModelRequired = 'reranking-model-required',
rerankingModelInvalid = 'reranking-model-invalid',
}
type KnowledgeBaseValidationIssue = {
code: KnowledgeBaseValidationIssueCode
}
type KnowledgeBaseValidationPayload = Pick<KnowledgeBaseNodeType, 'chunk_structure' | 'index_chunk_variable_selector' | 'indexing_technique' | 'embedding_model' | 'embedding_model_provider' | '_embeddingModelList' | '_embeddingProviderModelList' | '_rerankModelList'> & {
retrieval_model?: Pick<KnowledgeBaseNodeType['retrieval_model'], 'search_method' | 'reranking_enable' | 'reranking_model'>
}
const EMBEDDING_ISSUE_CODES = new Set<KnowledgeBaseValidationIssueCode>([
KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured,
KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired,
KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable,
KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted,
KnowledgeBaseValidationIssueCode.embeddingModelDisabled,
KnowledgeBaseValidationIssueCode.embeddingModelIncompatible,
])
const resolveIssue = (code: KnowledgeBaseValidationIssueCode): KnowledgeBaseValidationIssue => ({
code,
})
const resolveEmbeddingIssue = (payload: KnowledgeBaseValidationPayload): KnowledgeBaseValidationIssue | null => {
const {
embedding_model,
embedding_model_provider,
_embeddingModelList,
_embeddingProviderModelList,
} = payload
if (!embedding_model || !embedding_model_provider)
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured)
const currentEmbeddingModelProvider = _embeddingModelList?.find(provider => provider.provider === embedding_model_provider)
const hasProviderScopedModelList = !!_embeddingProviderModelList && _embeddingProviderModelList.length > 0
const embeddingModelCandidates = hasProviderScopedModelList
? _embeddingProviderModelList
: currentEmbeddingModelProvider?.models
const currentEmbeddingModel = embeddingModelCandidates?.find(model => model.model === embedding_model)
if (!currentEmbeddingModel) {
if (!currentEmbeddingModelProvider)
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible)
const providerExists = hasProviderScopedModelList || currentEmbeddingModelProvider
return resolveIssue(providerExists
? KnowledgeBaseValidationIssueCode.embeddingModelIncompatible
: KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured)
}
switch (currentEmbeddingModel.status) {
case ModelStatusEnum.active:
return null
case ModelStatusEnum.noConfigure:
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired)
case ModelStatusEnum.credentialRemoved:
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable)
case ModelStatusEnum.quotaExceeded:
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted)
case ModelStatusEnum.disabled:
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelDisabled)
case ModelStatusEnum.noPermission:
default:
return resolveIssue(KnowledgeBaseValidationIssueCode.embeddingModelIncompatible)
}
}
export const getKnowledgeBaseValidationIssue = (payload: KnowledgeBaseValidationPayload): KnowledgeBaseValidationIssue | null => {
const {
chunk_structure,
indexing_technique,
retrieval_model,
index_chunk_variable_selector,
_rerankModelList,
} = payload
const {
search_method,
reranking_enable,
reranking_model,
} = retrieval_model || {}
if (!chunk_structure)
return resolveIssue(KnowledgeBaseValidationIssueCode.chunkStructureRequired)
if (index_chunk_variable_selector.length === 0)
return resolveIssue(KnowledgeBaseValidationIssueCode.chunksVariableRequired)
if (!indexing_technique)
return resolveIssue(KnowledgeBaseValidationIssueCode.indexMethodRequired)
if (indexing_technique === IndexingType.QUALIFIED) {
const embeddingIssue = resolveEmbeddingIssue(payload)
if (embeddingIssue)
return embeddingIssue
}
if (!retrieval_model || !search_method)
return resolveIssue(KnowledgeBaseValidationIssueCode.retrievalSettingRequired)
if (reranking_enable) {
if (!reranking_model || !reranking_model.reranking_provider_name || !reranking_model.reranking_model_name)
return resolveIssue(KnowledgeBaseValidationIssueCode.rerankingModelRequired)
const currentRerankingModelProvider = _rerankModelList?.find(provider => provider.provider === reranking_model.reranking_provider_name)
const currentRerankingModel = currentRerankingModelProvider?.models.find(model => model.model === reranking_model.reranking_model_name)
if (!currentRerankingModel)
return resolveIssue(KnowledgeBaseValidationIssueCode.rerankingModelInvalid)
}
return null
}
export const getKnowledgeBaseValidationMessage = (
issue: KnowledgeBaseValidationIssue | null | undefined,
t: TFunction,
) => {
if (!issue)
return ''
switch (issue.code) {
case KnowledgeBaseValidationIssueCode.chunkStructureRequired:
return t('nodes.knowledgeBase.chunkIsRequired', { ns: 'workflow' })
case KnowledgeBaseValidationIssueCode.chunksVariableRequired:
return t('nodes.knowledgeBase.chunksVariableIsRequired', { ns: 'workflow' })
case KnowledgeBaseValidationIssueCode.indexMethodRequired:
return t('nodes.knowledgeBase.indexMethodIsRequired', { ns: 'workflow' })
case KnowledgeBaseValidationIssueCode.embeddingModelNotConfigured:
return t('nodes.knowledgeBase.embeddingModelNotConfigured', { ns: 'workflow' })
case KnowledgeBaseValidationIssueCode.embeddingModelConfigureRequired:
return t('modelProvider.selector.configureRequired', { ns: 'common' })
case KnowledgeBaseValidationIssueCode.embeddingModelApiKeyUnavailable:
return t('modelProvider.selector.apiKeyUnavailable', { ns: 'common' })
case KnowledgeBaseValidationIssueCode.embeddingModelCreditsExhausted:
return t('modelProvider.selector.creditsExhausted', { ns: 'common' })
case KnowledgeBaseValidationIssueCode.embeddingModelDisabled:
return t('modelProvider.selector.disabled', { ns: 'common' })
case KnowledgeBaseValidationIssueCode.embeddingModelIncompatible:
return t('modelProvider.selector.incompatible', { ns: 'common' })
case KnowledgeBaseValidationIssueCode.retrievalSettingRequired:
return t('nodes.knowledgeBase.retrievalSettingIsRequired', { ns: 'workflow' })
case KnowledgeBaseValidationIssueCode.rerankingModelRequired:
return t('nodes.knowledgeBase.rerankingModelIsRequired', { ns: 'workflow' })
case KnowledgeBaseValidationIssueCode.rerankingModelInvalid:
return t('nodes.knowledgeBase.rerankingModelIsInvalid', { ns: 'workflow' })
default:
return ''
}
}
export const isKnowledgeBaseEmbeddingIssue = (issue: KnowledgeBaseValidationIssue | null | undefined) => {
if (!issue)
return false
return EMBEDDING_ISSUE_CODES.has(issue.code)
}

View File

@ -0,0 +1,47 @@
import type { LLMNodeType } from './types'
import { AppModeEnum } from '@/types/app'
import { EditionType, PromptRole } from '../../types'
import nodeDefault from './default'
const t = (key: string) => key
const createPayload = (overrides: Partial<LLMNodeType> = {}): LLMNodeType => ({
...nodeDefault.defaultValue,
model: {
...nodeDefault.defaultValue.model,
provider: 'langgenius/openai/gpt-4.1',
mode: AppModeEnum.CHAT,
},
prompt_template: [{
role: PromptRole.system,
text: 'You are helpful.',
edition_type: EditionType.basic,
}],
...overrides,
}) as LLMNodeType
describe('llm default node validation', () => {
it('should require a model provider before validating the prompt', () => {
const result = nodeDefault.checkValid(createPayload({
model: {
...nodeDefault.defaultValue.model,
provider: '',
name: 'gpt-4.1',
mode: AppModeEnum.CHAT,
completion_params: {
temperature: 0.7,
},
},
}), t)
expect(result.isValid).toBe(false)
expect(result.errorMessage).toBe('errorMsg.fieldRequired')
})
it('should return a valid result when the provider and prompt are configured', () => {
const result = nodeDefault.checkValid(createPayload(), t)
expect(result.isValid).toBe(true)
expect(result.errorMessage).toBe('')
})
})

View File

@ -4,6 +4,7 @@ import { genNodeMetaData } from '@/app/components/workflow/utils'
// import { RETRIEVAL_OUTPUT_STRUCT } from '../../constants'
import { AppModeEnum } from '@/types/app'
import { BlockEnum, EditionType, PromptRole } from '../../types'
import { getLLMModelIssue, LLMModelIssueCode } from './utils'
const RETRIEVAL_OUTPUT_STRUCT = `{
"content": "",
@ -60,7 +61,8 @@ const nodeDefault: NodeDefault<LLMNodeType> = {
},
checkValid(payload: LLMNodeType, t: any) {
let errorMessages = ''
if (!errorMessages && !payload.model.provider)
const modelIssue = getLLMModelIssue({ modelProvider: payload.model.provider })
if (!errorMessages && modelIssue === LLMModelIssueCode.providerRequired)
errorMessages = t(`${i18nPrefix}.fieldRequired`, { ns: 'workflow', field: t(`${i18nPrefix}.fields.model`, { ns: 'workflow' }) })
if (!errorMessages && !payload.memory) {

View File

@ -0,0 +1,248 @@
import type { LLMNodeType } from './types'
import type { ModelProvider } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ProviderContextState } from '@/context/provider-context'
import type { PanelProps } from '@/types/workflow'
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import { defaultPlan } from '@/app/components/billing/config'
import {
ConfigurationMethodEnum,
CurrentSystemQuotaTypeEnum,
CustomConfigurationStatusEnum,
ModelTypeEnum,
PreferredProviderTypeEnum,
} from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useProviderContextSelector } from '@/context/provider-context'
import { AppModeEnum } from '@/types/app'
import { BlockEnum } from '../../types'
import Panel from './panel'
const mockUseConfig = vi.fn()
vi.mock('@/context/provider-context', () => ({
useProviderContextSelector: vi.fn(),
}))
vi.mock('./use-config', () => ({
default: (...args: unknown[]) => mockUseConfig(...args),
}))
vi.mock('@/app/components/header/account-setting/model-provider-page/model-parameter-modal', () => ({
default: () => <div data-testid="model-parameter-modal" />,
}))
vi.mock('./components/config-prompt', () => ({
default: () => <div data-testid="config-prompt" />,
}))
vi.mock('../_base/components/config-vision', () => ({
default: () => null,
}))
vi.mock('../_base/components/memory-config', () => ({
default: () => null,
}))
vi.mock('../_base/components/variable/var-reference-picker', () => ({
default: () => null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/prompt/editor', () => ({
default: () => null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-list', () => ({
default: () => null,
}))
vi.mock('./components/reasoning-format-config', () => ({
default: () => null,
}))
vi.mock('./components/structure-output', () => ({
default: () => null,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
default: ({ children }: { children?: React.ReactNode }) => <div>{children}</div>,
VarItem: () => null,
}))
type MockUseConfigReturn = ReturnType<typeof mockUseConfig>
const modelProviderSelector = vi.mocked(useProviderContextSelector)
const createProviderContextState = (modelProviders: ModelProvider[]): ProviderContextState => ({
modelProviders,
refreshModelProviders: vi.fn(),
textGenerationModelList: [],
supportRetrievalMethods: [],
isAPIKeySet: true,
plan: defaultPlan,
isFetchedPlan: true,
enableBilling: false,
onPlanInfoChanged: vi.fn(),
enableReplaceWebAppLogo: false,
modelLoadBalancingEnabled: false,
datasetOperatorEnabled: false,
enableEducationPlan: false,
isEducationWorkspace: false,
isEducationAccount: false,
allowRefreshEducationVerify: false,
educationAccountExpireAt: null,
isLoadingEducationAccountInfo: false,
isFetchingEducationAccountInfo: false,
webappCopyrightEnabled: false,
licenseLimit: {
workspace_members: {
size: 0,
limit: 0,
},
},
refreshLicenseLimit: vi.fn(),
isAllowTransferWorkspace: false,
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
humanInputEmailDeliveryEnabled: false,
})
const createMockModelProvider = (provider: string): ModelProvider => ({
provider,
label: { en_US: provider, zh_Hans: provider },
help: {
title: { en_US: provider, zh_Hans: provider },
url: { en_US: '', zh_Hans: '' },
},
icon_small: { en_US: '', zh_Hans: '' },
supported_model_types: [ModelTypeEnum.textGeneration],
configurate_methods: [ConfigurationMethodEnum.predefinedModel],
provider_credential_schema: {
credential_form_schemas: [],
},
model_credential_schema: {
model: {
label: { en_US: '', zh_Hans: '' },
placeholder: { en_US: '', zh_Hans: '' },
},
credential_form_schemas: [],
},
preferred_provider_type: PreferredProviderTypeEnum.system,
custom_configuration: {
status: CustomConfigurationStatusEnum.active,
},
system_configuration: {
enabled: true,
current_quota_type: CurrentSystemQuotaTypeEnum.free,
quota_configurations: [],
},
})
const baseNodeData: LLMNodeType = {
type: BlockEnum.LLM,
title: 'LLM',
desc: '',
model: {
provider: 'openai',
name: 'gpt-4o',
mode: AppModeEnum.CHAT,
completion_params: {},
},
prompt_template: [],
context: {
enabled: false,
variable_selector: [],
},
vision: {
enabled: false,
},
}
const panelProps = {} as PanelProps
const buildUseConfigResult = (overrides?: Partial<MockUseConfigReturn>) => ({
readOnly: false,
inputs: baseNodeData,
isChatModel: true,
isChatMode: true,
isCompletionModel: false,
shouldShowContextTip: false,
isVisionModel: false,
handleModelChanged: vi.fn(),
hasSetBlockStatus: false,
handleCompletionParamsChange: vi.fn(),
handleContextVarChange: vi.fn(),
filterInputVar: vi.fn(),
filterVar: vi.fn(),
availableVars: [],
availableNodesWithParent: [],
isShowVars: false,
handlePromptChange: vi.fn(),
handleAddEmptyVariable: vi.fn(),
handleAddVariable: vi.fn(),
handleVarListChange: vi.fn(),
handleVarNameChange: vi.fn(),
handleSyeQueryChange: vi.fn(),
handleMemoryChange: vi.fn(),
handleVisionResolutionEnabledChange: vi.fn(),
handleVisionResolutionChange: vi.fn(),
isModelSupportStructuredOutput: false,
structuredOutputCollapsed: false,
setStructuredOutputCollapsed: vi.fn(),
handleStructureOutputEnableChange: vi.fn(),
handleStructureOutputChange: vi.fn(),
filterJinja2InputVar: vi.fn(),
handleReasoningFormatChange: vi.fn(),
...overrides,
})
const renderPanel = (data?: Partial<LLMNodeType>) => {
return render(
<Panel
id="llm-node"
data={{ ...baseNodeData, ...data }}
panelProps={panelProps}
/>,
)
}
describe('LLM Panel', () => {
beforeEach(() => {
vi.clearAllMocks()
modelProviderSelector.mockImplementation(selector => selector(
createProviderContextState([createMockModelProvider('openai')]),
))
mockUseConfig.mockReturnValue(buildUseConfigResult())
})
describe('Model Warning Dot', () => {
it('should not show the model warning dot when the node only has a connection checklist issue', () => {
renderPanel()
const modelField = screen.getByText('workflow.nodes.llm.model').parentElement
expect(modelField?.querySelector('.bg-text-warning-secondary')).not.toBeInTheDocument()
})
it('should show the model warning dot when the model is not configured', () => {
mockUseConfig.mockReturnValue(buildUseConfigResult({
inputs: {
...baseNodeData,
model: {
...baseNodeData.model,
provider: '',
name: '',
},
},
}))
renderPanel({
model: {
...baseNodeData.model,
provider: '',
name: '',
},
})
const modelField = screen.getByText('workflow.nodes.llm.model').parentElement
expect(modelField?.querySelector('.bg-text-warning-secondary')).toBeInTheDocument()
})
})
})

View File

@ -15,7 +15,9 @@ import OutputVars, { VarItem } from '@/app/components/workflow/nodes/_base/compo
import Editor from '@/app/components/workflow/nodes/_base/components/prompt/editor'
import Split from '@/app/components/workflow/nodes/_base/components/split'
import VarList from '@/app/components/workflow/nodes/_base/components/variable/var-list'
import { useProviderContextSelector } from '@/context/provider-context'
import { fetchAndMergeValidCompletionParams } from '@/utils/completion-params'
import { extractPluginId } from '../../utils/plugin'
import ConfigVision from '../_base/components/config-vision'
import MemoryConfig from '../_base/components/memory-config'
import VarReferencePicker from '../_base/components/variable/var-reference-picker'
@ -23,6 +25,7 @@ import ConfigPrompt from './components/config-prompt'
import ReasoningFormatConfig from './components/reasoning-format-config'
import StructureOutput from './components/structure-output'
import useConfig from './use-config'
import { getLLMModelIssue, LLMModelIssueCode } from './utils'
const i18nPrefix = 'nodes.llm'
@ -67,6 +70,18 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
} = useConfig(id, data)
const model = inputs.model
const isModelProviderInstalled = useProviderContextSelector((state) => {
const modelIssue = getLLMModelIssue({ modelProvider: model?.provider })
if (modelIssue === LLMModelIssueCode.providerRequired)
return true
const modelProviderPluginId = extractPluginId(model.provider)
return state.modelProviders.some(provider => extractPluginId(provider.provider) === modelProviderPluginId)
})
const hasModelWarning = getLLMModelIssue({
modelProvider: model?.provider,
isModelProviderInstalled,
}) !== null
const handleModelChange = useCallback((model: {
provider: string
@ -102,6 +117,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
<Field
title={t(`${i18nPrefix}.model`, { ns: 'workflow' })}
required
warningDot={hasModelWarning}
>
<ModelParameterModal
popupClassName="!w-[387px]"
@ -264,8 +280,8 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
noDecoration
popupContent={(
<div className="w-[232px] rounded-xl border-[0.5px] border-components-panel-border bg-components-tooltip-bg px-4 py-3.5 shadow-lg backdrop-blur-[5px]">
<div className="title-xs-semi-bold text-text-primary">{t('structOutput.modelNotSupported', { ns: 'app' })}</div>
<div className="body-xs-regular mt-1 text-text-secondary">{t('structOutput.modelNotSupportedTip', { ns: 'app' })}</div>
<div className="text-text-primary title-xs-semi-bold">{t('structOutput.modelNotSupported', { ns: 'app' })}</div>
<div className="mt-1 text-text-secondary body-xs-regular">{t('structOutput.modelNotSupportedTip', { ns: 'app' })}</div>
</div>
)}
>
@ -274,7 +290,7 @@ const Panel: FC<NodePanelProps<LLMNodeType>> = ({
</div>
</Tooltip>
)}
<div className="system-xs-medium-uppercase mr-0.5 text-text-tertiary">{t('structOutput.structured', { ns: 'app' })}</div>
<div className="mr-0.5 text-text-tertiary system-xs-medium-uppercase">{t('structOutput.structured', { ns: 'app' })}</div>
<Tooltip popupContent={
<div className="max-w-[150px]">{t('structOutput.structuredTip', { ns: 'app' })}</div>
}

View File

@ -0,0 +1,43 @@
import { getLLMModelIssue, isLLMModelProviderInstalled, LLMModelIssueCode } from './utils'
describe('llm utils', () => {
describe('getLLMModelIssue', () => {
it('returns provider-required when the model provider is missing', () => {
expect(getLLMModelIssue({ modelProvider: undefined })).toBe(LLMModelIssueCode.providerRequired)
})
it('returns provider-plugin-unavailable when the provider plugin is not installed', () => {
expect(getLLMModelIssue({
modelProvider: 'langgenius/openai/gpt-4.1',
isModelProviderInstalled: false,
})).toBe(LLMModelIssueCode.providerPluginUnavailable)
})
it('returns null when the provider is present and installed', () => {
expect(getLLMModelIssue({
modelProvider: 'langgenius/openai/gpt-4.1',
isModelProviderInstalled: true,
})).toBeNull()
})
})
describe('isLLMModelProviderInstalled', () => {
it('returns true when the model provider is missing', () => {
expect(isLLMModelProviderInstalled(undefined, new Set())).toBe(true)
})
it('matches installed plugin ids using the provider plugin prefix', () => {
expect(isLLMModelProviderInstalled(
'langgenius/openai/gpt-4.1',
new Set(['langgenius/openai']),
)).toBe(true)
})
it('returns false when the provider plugin id is not installed', () => {
expect(isLLMModelProviderInstalled(
'langgenius/openai/gpt-4.1',
new Set(['langgenius/anthropic']),
)).toBe(false)
})
})
})

View File

@ -2,12 +2,41 @@ import type { ValidationError } from 'jsonschema'
import type { ArrayItems, Field, LLMNodeType } from './types'
import * as z from 'zod'
import { draft07Validator, forbidBooleanProperties } from '@/utils/validators'
import { extractPluginId } from '../../utils/plugin'
import { ArrayType, Type } from './types'
export const checkNodeValid = (_payload: LLMNodeType) => {
return true
}
export enum LLMModelIssueCode {
providerRequired = 'provider-required',
providerPluginUnavailable = 'provider-plugin-unavailable',
}
export const getLLMModelIssue = ({
modelProvider,
isModelProviderInstalled = true,
}: {
modelProvider?: string
isModelProviderInstalled?: boolean
}) => {
if (!modelProvider)
return LLMModelIssueCode.providerRequired
if (!isModelProviderInstalled)
return LLMModelIssueCode.providerPluginUnavailable
return null
}
export const isLLMModelProviderInstalled = (modelProvider: string | undefined, installedPluginIds: ReadonlySet<string>) => {
if (!modelProvider)
return true
return installedPluginIds.has(extractPluginId(modelProvider))
}
export const getFieldType = (field: Field) => {
const { type, items, enum: enums } = field
if (field.schemaType === 'file')

View File

@ -0,0 +1,33 @@
import { CollectionType } from '@/app/components/tools/types'
import { isToolAuthorizationRequired } from '../auth'
describe('isToolAuthorizationRequired', () => {
it('should return true for built-in tools that require authorization and are not authorized', () => {
expect(isToolAuthorizationRequired(CollectionType.builtIn, {
allow_delete: true,
is_team_authorization: false,
})).toBe(true)
})
it('should return false when the built-in tool is already authorized', () => {
expect(isToolAuthorizationRequired(CollectionType.builtIn, {
allow_delete: true,
is_team_authorization: true,
})).toBe(false)
})
it('should return false for non-built-in tools even if the provider is unauthorized', () => {
expect(isToolAuthorizationRequired(CollectionType.custom, {
allow_delete: true,
is_team_authorization: false,
})).toBe(false)
})
it('should return false when the collection is missing or authorization is not required', () => {
expect(isToolAuthorizationRequired(CollectionType.builtIn)).toBe(false)
expect(isToolAuthorizationRequired(CollectionType.builtIn, {
allow_delete: false,
is_team_authorization: false,
})).toBe(false)
})
})

View File

@ -0,0 +1,99 @@
import type { ToolNodeType } from '../types'
import { render, screen } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '@/app/components/workflow/types'
import Node from '../node'
const mockUseNodePluginInstallation = vi.hoisted(() => vi.fn())
const mockUseCurrentToolCollection = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks/use-node-plugin-installation', () => ({
useNodePluginInstallation: mockUseNodePluginInstallation,
}))
vi.mock('../hooks/use-current-tool-collection', () => ({
__esModule: true,
default: mockUseCurrentToolCollection,
}))
vi.mock('@/app/components/workflow/nodes/_base/components/install-plugin-button', () => ({
InstallPluginButton: () => <button type="button">Install Plugin</button>,
}))
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
title: 'Google Search',
desc: '',
type: BlockEnum.Tool,
provider_id: 'google_search',
provider_type: CollectionType.builtIn,
provider_name: 'Google Search',
tool_name: 'google_search',
tool_label: 'Google Search',
tool_parameters: {},
tool_configurations: {},
...overrides,
})
describe('ToolNode', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseNodePluginInstallation.mockReturnValue({
isChecking: false,
isMissing: false,
uniqueIdentifier: undefined,
canInstall: false,
onInstallSuccess: vi.fn(),
shouldDim: false,
})
mockUseCurrentToolCollection.mockReturnValue({
currentTools: [],
currCollection: undefined,
})
})
describe('Authorization Warning', () => {
it('should render the authorization warning when the tool requires authorization and is not authorized', () => {
mockUseCurrentToolCollection.mockReturnValue({
currentTools: [],
currCollection: {
allow_delete: true,
is_team_authorization: false,
},
})
render(<Node id="tool-node-1" data={createNodeData()} />)
expect(screen.getByText('workflow.nodes.tool.authorizationRequired')).toBeInTheDocument()
})
it('should keep configuration rows visible when the authorization warning is shown', () => {
mockUseCurrentToolCollection.mockReturnValue({
currentTools: [],
currCollection: {
allow_delete: true,
is_team_authorization: false,
},
})
render(
<Node
id="tool-node-1"
data={createNodeData({
tool_configurations: {
region: { value: 'us' },
},
})}
/>,
)
expect(screen.getByText('region')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.tool.authorizationRequired')).toBeInTheDocument()
})
it('should render nothing when there are no configs, no install action and no authorization warning', () => {
const { container } = render(<Node id="tool-node-1" data={createNodeData()} />)
expect(container).toBeEmptyDOMElement()
})
})
})

View File

@ -0,0 +1,309 @@
import type { ReactNode } from 'react'
import type { ToolNodeType } from '../types'
import type { CredentialFormSchema } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { NodePanelProps } from '@/app/components/workflow/types'
import { fireEvent, render, screen } from '@testing-library/react'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '@/app/components/workflow/types'
import Panel from '../panel'
const mockUseConfig = vi.hoisted(() => vi.fn())
const mockUseStore = vi.hoisted(() => vi.fn())
const mockUseMatchSchemaType = vi.hoisted(() => vi.fn())
const mockGetMatchedSchemaType = vi.hoisted(() => vi.fn())
const mockWrapStructuredVarItem = vi.hoisted(() => vi.fn())
const mockToolForm = vi.hoisted(() => vi.fn())
const mockStructureOutputItem = vi.hoisted(() => vi.fn())
const mockSplit = vi.hoisted(() => vi.fn())
vi.mock('../hooks/use-config', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseConfig(...args),
}))
vi.mock('@/app/components/workflow/store', () => ({
useStore: (selector: (state: typeof mockWorkflowStoreState) => unknown) => mockUseStore(selector),
}))
vi.mock('../../_base/components/variable/use-match-schema-type', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseMatchSchemaType(...args),
getMatchedSchemaType: (...args: unknown[]) => mockGetMatchedSchemaType(...args),
}))
vi.mock('@/app/components/workflow/utils/tool', () => ({
wrapStructuredVarItem: (...args: unknown[]) => mockWrapStructuredVarItem(...args),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/output-vars', () => ({
__esModule: true,
default: (props: {
title?: string
children: ReactNode
collapsed?: boolean
onCollapse?: (collapsed: boolean) => void
}) => (
<div data-testid="output-vars">
<div>{props.title ?? 'workflow.nodes.common.outputVars'}</div>
{props.onCollapse && (
<button type="button" onClick={() => props.onCollapse?.(!props.collapsed)}>
toggle-output-vars
</button>
)}
<div>{props.children}</div>
</div>
),
VarItem: (props: {
name: string
type: string
description: string
}) => (
<div data-testid={`var-item-${props.name}`}>
<span>{props.name}</span>
<span>{props.type}</span>
<span>{props.description}</span>
</div>
),
}))
vi.mock('../components/tool-form', () => ({
__esModule: true,
default: (props: {
schema: CredentialFormSchema[]
showManageInputField?: boolean
onManageInputField?: () => void
}) => {
mockToolForm(props)
return (
<div data-testid={`tool-form-${props.schema.map(item => item.variable).join('-') || 'empty'}`}>
{props.showManageInputField && props.onManageInputField && (
<button type="button" onClick={props.onManageInputField}>
Manage Input Field
</button>
)}
</div>
)
},
}))
vi.mock('@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/show', () => ({
__esModule: true,
default: (props: { payload: { id: string } }) => {
mockStructureOutputItem(props)
return <div data-testid="structured-output">{props.payload.id}</div>
},
}))
vi.mock('../../_base/components/split', () => ({
__esModule: true,
default: (props: { className?: string }) => {
mockSplit(props)
return <div data-testid="split">{props.className ?? 'default'}</div>
},
}))
const mockWorkflowStoreState = {
pipelineId: undefined as string | undefined,
setShowInputFieldPanel: vi.fn(),
}
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
title: 'Google Search',
desc: '',
type: BlockEnum.Tool,
provider_id: 'google_search',
provider_type: CollectionType.builtIn,
provider_name: 'Google Search',
tool_name: 'google_search',
tool_label: 'Google Search',
tool_parameters: {},
tool_configurations: {},
...overrides,
})
const createSchemaItem = (variable: string): CredentialFormSchema => ({
name: variable,
variable,
label: { en_US: variable, zh_Hans: variable },
type: FormTypeEnum.textInput,
required: false,
show_on: [],
})
const renderPanel = (data: ToolNodeType = createNodeData()) => {
const props: NodePanelProps<ToolNodeType> = {
id: 'tool-node-1',
data,
panelProps: {
getInputVars: vi.fn(() => []),
toVarInputs: vi.fn(() => []),
runInputData: {},
runInputDataRef: { current: {} },
setRunInputData: vi.fn(),
runResult: null,
},
}
return render(<Panel {...props} />)
}
const createConfigResult = (overrides: Record<string, unknown> = {}) => ({
readOnly: false,
inputs: {
tool_parameters: {},
tool_configurations: {},
},
toolInputVarSchema: [] as CredentialFormSchema[],
setInputVar: vi.fn(),
toolSettingSchema: [] as CredentialFormSchema[],
toolSettingValue: {},
setToolSettingValue: vi.fn(),
currCollection: { name: 'google_search' },
isShowAuthBtn: false,
isLoading: false,
outputSchema: [],
hasObjectOutput: false,
currTool: { name: 'google_search' },
...overrides,
})
describe('ToolPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockWorkflowStoreState.pipelineId = undefined
mockWorkflowStoreState.setShowInputFieldPanel = vi.fn()
mockUseStore.mockImplementation(selector => selector(mockWorkflowStoreState))
mockUseMatchSchemaType.mockReturnValue({
schemaTypeDefinitions: [{ name: 'structured' }],
})
mockGetMatchedSchemaType.mockReturnValue('')
mockWrapStructuredVarItem.mockImplementation((outputItem, schemaType) => ({
id: `${outputItem.name}-${schemaType || 'plain'}`,
}))
mockUseConfig.mockReturnValue(createConfigResult())
})
describe('Loading State', () => {
it('should render loading when config data is still loading', () => {
mockUseConfig.mockReturnValue(createConfigResult({
isLoading: true,
}))
renderPanel()
expect(screen.getByRole('status')).toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.tool.inputVars')).not.toBeInTheDocument()
expect(mockToolForm).not.toHaveBeenCalled()
})
})
describe('Form Rendering', () => {
it('should render input and settings forms and forward the manage input field action', () => {
mockWorkflowStoreState.pipelineId = 'pipeline-1'
mockUseConfig.mockReturnValue(createConfigResult({
inputs: {
tool_parameters: { query: { value: 'weather' } },
tool_configurations: {},
},
toolInputVarSchema: [createSchemaItem('query')],
toolSettingSchema: [createSchemaItem('region')],
toolSettingValue: { region: 'us' },
}))
renderPanel()
expect(screen.getByText('workflow.nodes.tool.inputVars')).toBeInTheDocument()
expect(screen.getByText('workflow.nodes.tool.settings')).toBeInTheDocument()
expect(screen.getAllByTestId('split')).toHaveLength(2)
fireEvent.click(screen.getByRole('button', { name: 'Manage Input Field' }))
expect(mockToolForm).toHaveBeenCalledTimes(2)
expect(mockToolForm.mock.calls[0][0]).toEqual(expect.objectContaining({
nodeId: 'tool-node-1',
showManageInputField: true,
}))
expect(mockToolForm.mock.calls[1][0]).toEqual(expect.objectContaining({
nodeId: 'tool-node-1',
}))
expect(mockToolForm.mock.calls[1][0]).not.toHaveProperty('showManageInputField')
expect(mockWorkflowStoreState.setShowInputFieldPanel).toHaveBeenCalledWith(true)
})
it('should hide editable forms when the auth button is shown but keep output variables visible', () => {
mockUseConfig.mockReturnValue(createConfigResult({
isShowAuthBtn: true,
toolInputVarSchema: [createSchemaItem('query')],
toolSettingSchema: [createSchemaItem('region')],
}))
renderPanel()
expect(screen.queryByText('workflow.nodes.tool.inputVars')).not.toBeInTheDocument()
expect(screen.queryByText('workflow.nodes.tool.settings')).not.toBeInTheDocument()
expect(screen.getByText('text')).toBeInTheDocument()
expect(screen.getByText('files')).toBeInTheDocument()
expect(screen.getByText('json')).toBeInTheDocument()
expect(mockToolForm).not.toHaveBeenCalled()
})
})
describe('Output Schema', () => {
it('should render scalar and structured outputs with matched schema types', () => {
mockGetMatchedSchemaType.mockImplementation((value: { type?: string }) => {
return value?.type === 'string' ? 'qa_structured' : 'object_structured'
})
mockUseConfig.mockReturnValue(createConfigResult({
hasObjectOutput: true,
outputSchema: [
{
name: 'summary',
type: 'String',
description: 'Summary field',
value: { type: 'string' },
},
{
name: 'details',
type: 'Object',
description: 'Details field',
value: { type: 'object', properties: {} },
},
],
}))
renderPanel()
expect(screen.getByText('summary')).toBeInTheDocument()
expect(screen.getByText('string (qa_structured)')).toBeInTheDocument()
expect(screen.getByText('Summary field')).toBeInTheDocument()
expect(screen.getByTestId('structured-output')).toHaveTextContent('details-object_structured')
expect(mockWrapStructuredVarItem).toHaveBeenCalledWith(expect.objectContaining({
name: 'details',
}), 'object_structured')
expect(mockStructureOutputItem).toHaveBeenCalledWith(expect.objectContaining({
payload: { id: 'details-object_structured' },
}))
})
it('should render scalar outputs without a schema suffix when no schema type matches', () => {
mockGetMatchedSchemaType.mockReturnValue('')
mockUseConfig.mockReturnValue(createConfigResult({
outputSchema: [
{
name: 'summary',
type: 'String',
description: 'Summary field',
value: { type: 'string' },
},
],
}))
renderPanel()
expect(screen.getByTestId('var-item-summary')).toHaveTextContent('summary')
expect(screen.getByTestId('var-item-summary')).toHaveTextContent('String'.toLowerCase())
expect(screen.getByTestId('var-item-summary')).not.toHaveTextContent('qa_structured')
})
})
})

View File

@ -0,0 +1,14 @@
import type { ToolWithProvider } from '../../types'
import type { ToolNodeType } from './types'
import { CollectionType } from '@/app/components/tools/types'
type ToolAuthorizationCollection = Pick<ToolWithProvider, 'allow_delete' | 'is_team_authorization'>
export const isToolAuthorizationRequired = (
providerType: ToolNodeType['provider_type'],
collection?: ToolAuthorizationCollection,
) => {
return providerType === CollectionType.builtIn
&& !!collection?.allow_delete
&& collection?.is_team_authorization === false
}

View File

@ -0,0 +1,194 @@
import type { ToolNodeType } from '../../types'
import { renderHook } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '@/app/components/workflow/types'
import { VarType } from '../../types'
import useConfig from '../use-config'
const mockSetInputs = vi.hoisted(() => vi.fn())
const mockSetControlPromptEditorRerenderKey = vi.hoisted(() => vi.fn())
const mockUseCurrentToolCollection = vi.hoisted(() => vi.fn())
const mockGetConfiguredValue = vi.hoisted(() => vi.fn())
const mockToolParametersToFormSchemas = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/header/account-setting/model-provider-page/hooks', () => ({
useLanguage: () => 'en_US',
}))
vi.mock('@/app/components/workflow/hooks', () => ({
useNodesReadOnly: () => ({ nodesReadOnly: false }),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (_id: string, data: ToolNodeType) => ({
inputs: data,
setInputs: mockSetInputs,
}),
}))
vi.mock('@/app/components/tools/utils/to-form-schema', () => ({
getConfiguredValue: (...args: unknown[]) => mockGetConfiguredValue(...args),
toolParametersToFormSchemas: (...args: unknown[]) => mockToolParametersToFormSchemas(...args),
}))
vi.mock('@/service/tools', () => ({
updateBuiltInToolCredential: vi.fn(),
}))
vi.mock('@/service/use-tools', () => ({
useInvalidToolsByType: () => vi.fn(),
}))
vi.mock('@/app/components/workflow/store', () => ({
useWorkflowStore: () => ({
getState: () => ({
setControlPromptEditorRerenderKey: mockSetControlPromptEditorRerenderKey,
}),
}),
}))
vi.mock('../use-current-tool-collection', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseCurrentToolCollection(...args),
}))
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
title: 'Google Search',
desc: '',
type: BlockEnum.Tool,
provider_id: 'google_search',
provider_type: CollectionType.builtIn,
provider_name: 'Google Search',
tool_name: 'google_search',
tool_label: 'Google Search',
tool_parameters: {},
tool_configurations: {},
...overrides,
})
const currentTool = {
name: 'google_search',
parameters: [
{
variable: 'query',
form: 'llm',
label: { en_US: 'Query' },
type: 'string',
required: true,
default: 'default query',
},
{
variable: 'api_key',
form: 'credential',
label: { en_US: 'API Key' },
type: 'string',
required: true,
default: 'default secret',
},
],
}
const currentToolWithoutDefaults = {
name: 'google_search',
parameters: [
{
variable: 'query',
form: 'llm',
label: { en_US: 'Query' },
type: 'string',
required: true,
},
{
variable: 'api_key',
form: 'credential',
label: { en_US: 'API Key' },
type: 'string',
required: true,
},
],
}
const createToolVarInput = (value: string) => ({
type: VarType.mixed,
value,
})
describe('useConfig', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
mockUseCurrentToolCollection.mockReturnValue({
currCollection: {
name: 'google_search',
allow_delete: true,
is_team_authorization: false,
tools: [currentTool],
},
})
mockToolParametersToFormSchemas.mockImplementation(parameters => parameters)
mockGetConfiguredValue.mockImplementation((_value, schema: Array<{ variable: string, default?: string }>) => {
return schema.reduce<Record<string, ReturnType<typeof createToolVarInput>>>((acc, item) => {
acc[item.variable] = createToolVarInput(item.default || '')
return acc
}, {} as Record<string, ReturnType<typeof createToolVarInput>>)
})
})
afterEach(() => {
vi.runOnlyPendingTimers()
vi.useRealTimers()
})
describe('Default Value Sync', () => {
it('should apply default values only once when the current payload is initially empty', () => {
const emptyPayload = createNodeData()
const syncedPayload = createNodeData({
tool_parameters: { query: createToolVarInput('default query') },
tool_configurations: { api_key: createToolVarInput('default secret') },
})
const { rerender } = renderHook(
({ payload }) => useConfig('tool-node-1', payload),
{ initialProps: { payload: emptyPayload } },
)
expect(mockSetInputs).toHaveBeenCalledTimes(1)
expect(mockSetInputs).toHaveBeenCalledWith(expect.objectContaining({
tool_parameters: { query: createToolVarInput('default query') },
tool_configurations: { api_key: createToolVarInput('default secret') },
}))
rerender({ payload: syncedPayload })
expect(mockSetInputs).toHaveBeenCalledTimes(1)
})
it('should not update inputs when tool values are already populated on first render', () => {
renderHook(() => useConfig('tool-node-1', createNodeData({
tool_parameters: { query: createToolVarInput('existing query') },
tool_configurations: { api_key: createToolVarInput('existing secret') },
})))
expect(mockSetInputs).not.toHaveBeenCalled()
})
it('should not update inputs when empty schemas do not provide any default values', () => {
mockUseCurrentToolCollection.mockReturnValue({
currCollection: {
name: 'google_search',
allow_delete: true,
is_team_authorization: false,
tools: [currentToolWithoutDefaults],
},
})
mockGetConfiguredValue.mockReturnValue({})
renderHook(() => useConfig('tool-node-1', createNodeData()))
expect(mockSetInputs).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,84 @@
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { renderHook } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import useCurrentToolCollection from '../use-current-tool-collection'
const mockUseAllBuiltInTools = vi.hoisted(() => vi.fn())
const mockUseAllCustomTools = vi.hoisted(() => vi.fn())
const mockUseAllWorkflowTools = vi.hoisted(() => vi.fn())
const mockUseAllMCPTools = vi.hoisted(() => vi.fn())
const mockCanFindTool = vi.hoisted(() => vi.fn())
vi.mock('@/service/use-tools', () => ({
useAllBuiltInTools: () => mockUseAllBuiltInTools(),
useAllCustomTools: () => mockUseAllCustomTools(),
useAllWorkflowTools: () => mockUseAllWorkflowTools(),
useAllMCPTools: () => mockUseAllMCPTools(),
}))
vi.mock('@/utils', () => ({
canFindTool: (...args: unknown[]) => mockCanFindTool(...args),
}))
const createToolCollection = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
id: 'builtin-search',
name: 'Google Search',
type: CollectionType.builtIn,
label: { en_US: 'Google Search' },
description: { en_US: 'Search provider' },
icon: '',
icon_dark: '',
background: '',
tags: [],
tools: [],
allow_delete: true,
is_team_authorization: false,
meta: {} as ToolWithProvider['meta'],
...overrides,
}) as ToolWithProvider
describe('useCurrentToolCollection', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCanFindTool.mockImplementation((collectionId: string, providerId: string) => collectionId === providerId)
mockUseAllBuiltInTools.mockReturnValue({ data: [] })
mockUseAllCustomTools.mockReturnValue({ data: [] })
mockUseAllWorkflowTools.mockReturnValue({ data: [] })
mockUseAllMCPTools.mockReturnValue({ data: [] })
})
it('should return the built-in collection list and matched provider for built-in tools', () => {
const builtInCollection = createToolCollection({ id: 'builtin-search' })
mockUseAllBuiltInTools.mockReturnValue({ data: [builtInCollection] })
const { result } = renderHook(() => useCurrentToolCollection(CollectionType.builtIn, 'builtin-search'))
expect(result.current.currentTools).toEqual([builtInCollection])
expect(result.current.currCollection).toBe(builtInCollection)
expect(mockCanFindTool).toHaveBeenCalledWith('builtin-search', 'builtin-search')
})
it('should select the custom tool collection when the provider type is custom', () => {
const customCollection = createToolCollection({
id: 'custom-search',
type: CollectionType.custom,
})
mockUseAllCustomTools.mockReturnValue({ data: [customCollection] })
const { result } = renderHook(() => useCurrentToolCollection(CollectionType.custom, 'custom-search'))
expect(result.current.currentTools).toEqual([customCollection])
expect(result.current.currCollection).toBe(customCollection)
})
it('should return undefined when no collection matches the provider id', () => {
mockUseAllBuiltInTools.mockReturnValue({
data: [createToolCollection({ id: 'another-tool' })],
})
const { result } = renderHook(() => useCurrentToolCollection(CollectionType.builtIn, 'builtin-search'))
expect(result.current.currentTools).toHaveLength(1)
expect(result.current.currCollection).toBeUndefined()
})
})

View File

@ -0,0 +1,49 @@
import type { ToolNodeType } from '../../types'
import { renderHook } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '@/app/components/workflow/types'
import useGetDataForCheckMore from '../use-get-data-for-check-more'
const mockUseConfig = vi.hoisted(() => vi.fn())
vi.mock('../use-config', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseConfig(...args),
}))
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
title: 'Google Search',
desc: '',
type: BlockEnum.Tool,
provider_id: 'google_search',
provider_type: CollectionType.builtIn,
provider_name: 'Google Search',
tool_name: 'google_search',
tool_label: 'Google Search',
tool_parameters: {},
tool_configurations: {},
...overrides,
})
describe('useGetDataForCheckMore', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should expose the config hook validator as getData', () => {
const getMoreDataForCheckValid = vi.fn(() => ({ provider: 'google' }))
const payload = createNodeData()
mockUseConfig.mockReturnValue({
getMoreDataForCheckValid,
})
const { result } = renderHook(() => useGetDataForCheckMore({
id: 'tool-node-1',
payload,
}))
expect(mockUseConfig).toHaveBeenCalledWith('tool-node-1', payload)
expect(result.current.getData).toBe(getMoreDataForCheckValid)
expect(result.current.getData()).toEqual({ provider: 'google' })
})
})

View File

@ -0,0 +1,206 @@
import type { ToolNodeType, VarType } from '../../types'
import type { InputVar } from '@/app/components/workflow/types'
import type { NodeTracing } from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum, InputVarType } from '@/app/components/workflow/types'
import useSingleRunFormParams from '../use-single-run-form-params'
const mockUseToolIcon = vi.hoisted(() => vi.fn())
const mockUseNodeCrud = vi.hoisted(() => vi.fn())
const mockFormatToTracingNodeList = vi.hoisted(() => vi.fn())
vi.mock('@/app/components/workflow/hooks', () => ({
useToolIcon: (...args: unknown[]) => mockUseToolIcon(...args),
}))
vi.mock('@/app/components/workflow/nodes/_base/hooks/use-node-crud', () => ({
__esModule: true,
default: (...args: unknown[]) => mockUseNodeCrud(...args),
}))
vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatToTracingNodeList(...args),
}))
const createNodeData = (overrides: Partial<ToolNodeType> = {}): ToolNodeType => ({
title: 'Google Search',
desc: '',
type: BlockEnum.Tool,
provider_id: 'google_search',
provider_type: CollectionType.builtIn,
provider_name: 'Google Search',
tool_name: 'google_search',
tool_label: 'Google Search',
tool_parameters: {},
tool_configurations: {},
...overrides,
})
const createInputVar = (variable: InputVar['variable']): InputVar => ({
type: InputVarType.textInput,
label: typeof variable === 'string' ? variable : 'invalid',
variable,
required: false,
})
const createRunResult = (): NodeTracing => ({
id: 'trace-1',
index: 0,
predecessor_node_id: '',
node_id: 'tool-node-1',
node_type: BlockEnum.Tool,
title: 'Google Search',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs_truncated: false,
status: 'succeeded',
elapsed_time: 1,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 1,
})
describe('useSingleRunFormParams', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseToolIcon.mockReturnValue('tool-icon')
mockFormatToTracingNodeList.mockReturnValue([{ id: 'formatted-node' }])
mockUseNodeCrud.mockImplementation((_id: string, payload: ToolNodeType) => ({
inputs: payload,
}))
})
describe('Variable Extraction', () => {
it('should build form inputs from variable params and settings and expose dependent vars', () => {
const payload = createNodeData({
tool_parameters: {
query: { type: 'variable' as VarType, value: ['start', 'query'] },
legacy_query: { type: 'variable' as VarType, value: 'legacy.answer' },
constant_query: { type: 'constant' as VarType, value: 'fixed' },
},
tool_configurations: {
prompt: { type: 'mixed' as VarType, value: 'prefix {{#tool.result#}}' },
api_key: { type: 'constant' as VarType, value: 'secret' },
plainText: 'ignored',
},
})
const getInputVars = vi.fn(() => [
createInputVar('#start.query#'),
createInputVar(undefined as unknown as string),
createInputVar('#legacy.answer#'),
])
const { result } = renderHook(() => useSingleRunFormParams({
id: 'tool-node-1',
payload,
runInputData: {},
runInputDataRef: { current: {} },
getInputVars,
setRunInputData: vi.fn(),
toVarInputs: vi.fn(),
runResult: null as unknown as NodeTracing,
}))
expect(getInputVars).toHaveBeenCalledWith([
'{{#start.query#}}',
'{{#legacy.answer#}}',
'prefix {{#tool.result#}}',
])
expect(result.current.forms).toHaveLength(1)
expect(result.current.forms[0].inputs).toEqual([
createInputVar('#start.query#'),
createInputVar(undefined as unknown as string),
createInputVar('#legacy.answer#'),
])
expect(result.current.forms[0].values).toEqual({})
expect(result.current.toolIcon).toBe('tool-icon')
expect(result.current.getDependentVars()).toEqual([
['start', 'query'],
['legacy', 'answer'],
])
expect(result.current.nodeInfo).toBeNull()
})
})
describe('Form Updates', () => {
it('should update form values and forward run input data on change', () => {
const payload = createNodeData({
tool_parameters: {
nullable_constant: { type: 'constant' as VarType, value: null },
query: { type: 'variable' as VarType, value: ['start', 'query'] },
},
})
const getInputVars = vi.fn(() => [createInputVar('#start.query#')])
const setRunInputData = vi.fn()
const { result } = renderHook(() => useSingleRunFormParams({
id: 'tool-node-1',
payload,
runInputData: {},
runInputDataRef: { current: {} },
getInputVars,
setRunInputData,
toVarInputs: vi.fn(),
runResult: null as unknown as NodeTracing,
}))
act(() => {
result.current.forms[0].onChange({
query: 'weather',
tool_parameters: {
nullable_constant: 'temp-value',
},
})
})
expect(setRunInputData).toHaveBeenCalledWith({
query: 'weather',
tool_parameters: {
nullable_constant: 'temp-value',
},
})
expect(result.current.forms[0].values).toEqual({
query: 'weather',
tool_parameters: {
nullable_constant: 'temp-value',
},
nullable_constant: null,
})
})
})
describe('Tracing Data', () => {
it('should format the latest run result into node info when a run result exists', () => {
const payload = createNodeData()
const runResult = createRunResult()
const { result } = renderHook(() => useSingleRunFormParams({
id: 'tool-node-1',
payload,
runInputData: {},
runInputDataRef: { current: {} },
getInputVars: vi.fn(() => []),
setRunInputData: vi.fn(),
toVarInputs: vi.fn(),
runResult,
}))
expect(mockFormatToTracingNodeList).toHaveBeenCalledWith([runResult], expect.any(Function))
expect(result.current.nodeInfo).toEqual({ id: 'formatted-node' })
})
})
})

View File

@ -1,4 +1,4 @@
import type { ToolNodeType, ToolVarInputs } from './types'
import type { ToolNodeType, ToolVarInputs } from '../types'
import type { InputVar } from '@/app/components/workflow/types'
import { useBoolean } from 'ahooks'
import { capitalize } from 'es-toolkit/string'
@ -16,17 +16,14 @@ import {
useNodesReadOnly,
} from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import { useWorkflowStore } from '@/app/components/workflow/store'
import { updateBuiltInToolCredential } from '@/service/tools'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
useInvalidToolsByType,
} from '@/service/use-tools'
import { canFindTool } from '@/utils'
import { useWorkflowStore } from '../../store'
import { normalizeJsonSchemaType } from './output-schema-utils'
import { isToolAuthorizationRequired } from '../auth'
import { normalizeJsonSchemaType } from '../output-schema-utils'
import useCurrentToolCollection from './use-current-tool-collection'
const formatDisplayType = (output: Record<string, unknown>): string => {
const normalizedType = normalizeJsonSchemaType(output) || 'Unknown'
@ -55,33 +52,10 @@ const useConfig = (id: string, payload: ToolNodeType) => {
tool_parameters,
} = inputs
const isBuiltIn = provider_type === CollectionType.builtIn
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const currentTools = useMemo(() => {
switch (provider_type) {
case CollectionType.builtIn:
return buildInTools || []
case CollectionType.custom:
return customTools || []
case CollectionType.workflow:
return workflowTools || []
case CollectionType.mcp:
return mcpTools || []
default:
return []
}
}, [buildInTools, customTools, mcpTools, provider_type, workflowTools])
const currCollection = useMemo(() => {
return currentTools.find(item => canFindTool(item.id, provider_id))
}, [currentTools, provider_id])
const { currCollection } = useCurrentToolCollection(provider_type, provider_id)
// Auth
const needAuth = !!currCollection?.allow_delete
const isAuthed = !!currCollection?.is_team_authorization
const isShowAuthBtn = isBuiltIn && needAuth && !isAuthed
const isShowAuthBtn = isToolAuthorizationRequired(provider_type, currCollection)
const [
showSetAuth,
{ setTrue: showSetAuthModal, setFalse: hideSetAuthModal },
@ -104,7 +78,6 @@ const useConfig = (id: string, payload: ToolNodeType) => {
hideSetAuthModal,
t,
invalidToolsByType,
provider_type,
],
)
@ -172,38 +145,49 @@ const useConfig = (id: string, payload: ToolNodeType) => {
[inputs, setInputs],
)
const formattingParameters = () => {
const formattingParameters = useCallback(() => {
const inputsWithDefaultValue = produce(inputs, (draft) => {
if (
!draft.tool_configurations
|| Object.keys(draft.tool_configurations).length === 0
) {
draft.tool_configurations = getConfiguredValue(
const configuredToolSettings = getConfiguredValue(
tool_configurations,
toolSettingSchema,
) as ToolVarInputs
if (Object.keys(configuredToolSettings).length > 0)
draft.tool_configurations = configuredToolSettings
}
if (
!draft.tool_parameters
|| Object.keys(draft.tool_parameters).length === 0
) {
draft.tool_parameters = getConfiguredValue(
const configuredToolParameters = getConfiguredValue(
tool_parameters,
toolInputVarSchema,
) as ToolVarInputs
if (Object.keys(configuredToolParameters).length > 0)
draft.tool_parameters = configuredToolParameters
}
})
return inputsWithDefaultValue
}
}, [inputs, toolInputVarSchema, toolSettingSchema, tool_configurations, tool_parameters])
useEffect(() => {
if (!currTool)
return
const inputsWithDefaultValue = formattingParameters()
if (inputsWithDefaultValue === inputs)
return
const { setControlPromptEditorRerenderKey } = workflowStore.getState()
setInputs(inputsWithDefaultValue)
setTimeout(() => setControlPromptEditorRerenderKey(Date.now()))
}, [currTool])
const rerenderTimeout = setTimeout(() => setControlPromptEditorRerenderKey(Date.now()))
return () => {
clearTimeout(rerenderTimeout)
}
}, [currTool, formattingParameters, inputs, setInputs, workflowStore])
// setting when call
const setInputVar = useCallback(

View File

@ -0,0 +1,47 @@
import type { ToolNodeType } from '../types'
import type { ToolWithProvider } from '@/app/components/workflow/types'
import { useMemo } from 'react'
import { CollectionType } from '@/app/components/tools/types'
import {
useAllBuiltInTools,
useAllCustomTools,
useAllMCPTools,
useAllWorkflowTools,
} from '@/service/use-tools'
import { canFindTool } from '@/utils'
const useCurrentToolCollection = (
providerType: ToolNodeType['provider_type'],
providerId: string,
) => {
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
const { data: mcpTools } = useAllMCPTools()
const currentTools = useMemo<ToolWithProvider[]>(() => {
switch (providerType) {
case CollectionType.builtIn:
return buildInTools || []
case CollectionType.custom:
return customTools || []
case CollectionType.workflow:
return workflowTools || []
case CollectionType.mcp:
return mcpTools || []
default:
return []
}
}, [buildInTools, customTools, mcpTools, providerType, workflowTools])
const currCollection = useMemo(() => {
return currentTools.find(item => canFindTool(item.id, providerId))
}, [currentTools, providerId])
return {
currentTools,
currCollection,
}
}
export default useCurrentToolCollection

View File

@ -1,4 +1,4 @@
import type { ToolNodeType } from './types'
import type { ToolNodeType } from '../types'
import useConfig from './use-config'
type Params = {

View File

@ -1,15 +1,15 @@
import type { RefObject } from 'react'
import type { ToolNodeType } from './types'
import type { ToolNodeType } from '../types'
import type { Props as FormProps } from '@/app/components/workflow/nodes/_base/components/before-run-form/form'
import type { InputVar, ValueSelector, Variable } from '@/app/components/workflow/types'
import type { NodeTracing } from '@/types/workflow'
import { produce } from 'immer'
import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useToolIcon } from '@/app/components/workflow/hooks'
import useNodeCrud from '@/app/components/workflow/nodes/_base/hooks/use-node-crud'
import formatToTracingNodeList from '@/app/components/workflow/run/utils/format-log'
import { useToolIcon } from '../../hooks'
import useNodeCrud from '../_base/hooks/use-node-crud'
import { VarType } from './types'
import { VarType } from '../types'
type Params = {
id: string
@ -50,9 +50,9 @@ const useSingleRunFormParams = ({
return p.value as string
}))
const [inputVarValues, doSetInputVarValues] = useState<Record<string, any>>({})
const setInputVarValues = useCallback((value: Record<string, any>) => {
doSetInputVarValues(value)
const [inputVarValues, setInputVarValues] = useState<Record<string, any>>({})
const handleInputVarValuesChange = useCallback((value: Record<string, any>) => {
setInputVarValues(value)
setRunInputData(value)
}, [setRunInputData])
@ -74,10 +74,10 @@ const useSingleRunFormParams = ({
const forms: FormProps[] = [{
inputs: varInputs,
values: inputVarValuesWithConstantValue(),
onChange: setInputVarValues,
onChange: handleInputVarValuesChange,
}]
return forms
}, [inputVarValuesWithConstantValue, setInputVarValues, varInputs])
}, [handleInputVarValuesChange, inputVarValuesWithConstantValue, varInputs])
const nodeInfo = useMemo(() => {
if (!runResult)

View File

@ -2,16 +2,17 @@ import type { FC } from 'react'
import type { ToolNodeType } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
import * as React from 'react'
import { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import { FormTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
import { isToolAuthorizationRequired } from './auth'
import useCurrentToolCollection from './hooks/use-current-tool-collection'
const Node: FC<NodeProps<ToolNodeType>> = ({
id,
data,
}) => {
const { t } = useTranslation()
const { tool_configurations, paramSchemas } = data
const toolConfigs = Object.keys(tool_configurations || {})
const {
@ -22,25 +23,13 @@ const Node: FC<NodeProps<ToolNodeType>> = ({
onInstallSuccess,
shouldDim,
} = useNodePluginInstallation(data)
const { currCollection } = useCurrentToolCollection(data.provider_type, data.provider_id)
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
const { handleNodeDataUpdate } = useNodeDataUpdate()
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
useEffect(() => {
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
return
handleNodeDataUpdate({
id,
data: {
_pluginInstallLocked: shouldLock,
_dimmed: shouldDim,
},
})
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
const showAuthorizationWarning = isToolAuthorizationRequired(data.provider_type, currCollection)
const hasConfigs = toolConfigs.length > 0
if (!showInstallButton && !hasConfigs)
if (!showInstallButton && !hasConfigs && !showAuthorizationWarning)
return null
return (
@ -60,10 +49,10 @@ const Node: FC<NodeProps<ToolNodeType>> = ({
/>
</div>
)}
{hasConfigs && (
{(hasConfigs || showAuthorizationWarning) && (
<div className="space-y-0.5" aria-disabled={shouldDim}>
{toolConfigs.map((key, index) => (
<div key={index} className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary">
{hasConfigs && toolConfigs.map(key => (
<div key={key} className="flex h-6 items-center justify-between space-x-1 rounded-md bg-workflow-block-parma-bg px-1 text-xs font-normal text-text-secondary">
<div title={key} className="max-w-[100px] shrink-0 truncate text-xs font-medium uppercase text-text-tertiary">
{key}
</div>
@ -84,6 +73,14 @@ const Node: FC<NodeProps<ToolNodeType>> = ({
)}
</div>
))}
{showAuthorizationWarning && (
<div className="flex h-6 items-center rounded-md border-[0.5px] border-state-warning-active bg-state-warning-hover px-1.5">
<span className="mr-1 size-[4px] shrink-0 rounded-[2px] bg-text-warning-secondary" />
<div className="grow truncate text-text-warning system-xs-medium" title={t('nodes.tool.authorizationRequired', { ns: 'workflow' })}>
{t('nodes.tool.authorizationRequired', { ns: 'workflow' })}
</div>
</div>
)}
</div>
)}
</div>

View File

@ -12,7 +12,7 @@ import { wrapStructuredVarItem } from '@/app/components/workflow/utils/tool'
import Split from '../_base/components/split'
import useMatchSchemaType, { getMatchedSchemaType } from '../_base/components/variable/use-match-schema-type'
import ToolForm from './components/tool-form'
import useConfig from './use-config'
import useConfig from './hooks/use-config'
const i18nPrefix = 'nodes.tool'

View File

@ -2,10 +2,9 @@ import type { FC } from 'react'
import type { PluginTriggerNodeType } from './types'
import type { NodeProps } from '@/app/components/workflow/types'
import * as React from 'react'
import { useEffect, useMemo } from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import NodeStatus, { NodeStatusEnum } from '@/app/components/base/node-status'
import { useNodeDataUpdate } from '@/app/components/workflow/hooks/use-node-data-update'
import { useNodePluginInstallation } from '@/app/components/workflow/hooks/use-node-plugin-installation'
import { InstallPluginButton } from '@/app/components/workflow/nodes/_base/components/install-plugin-button'
import useConfig from './use-config'
@ -54,21 +53,7 @@ const Node: FC<NodeProps<PluginTriggerNodeType>> = ({
onInstallSuccess,
shouldDim,
} = useNodePluginInstallation(data)
const { handleNodeDataUpdate } = useNodeDataUpdate()
const showInstallButton = !isChecking && isMissing && canInstall && uniqueIdentifier
const shouldLock = !isChecking && isMissing && canInstall && Boolean(uniqueIdentifier)
useEffect(() => {
if (data._pluginInstallLocked === shouldLock && data._dimmed === shouldDim)
return
handleNodeDataUpdate({
id,
data: {
_pluginInstallLocked: shouldLock,
_dimmed: shouldDim,
},
})
}, [data._pluginInstallLocked, data._dimmed, handleNodeDataUpdate, id, shouldDim, shouldLock])
const { t } = useTranslation()

View File

@ -96,7 +96,7 @@ const ObjectValueItem: FC<Props> = ({
{/* Key */}
<div className="w-[120px] border-r border-gray-200">
<input
className="system-xs-regular placeholder:system-xs-regular block h-7 w-full appearance-none px-2 text-text-secondary caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:bg-state-base-hover focus:bg-components-input-bg-active"
className="block h-7 w-full appearance-none px-2 text-text-secondary caret-primary-600 outline-none system-xs-regular placeholder:text-components-input-text-placeholder placeholder:system-xs-regular hover:bg-state-base-hover focus:bg-components-input-bg-active"
placeholder={t('chatVariable.modal.objectKey', { ns: 'workflow' }) || ''}
value={list[index].key}
onChange={handleKeyChange(index)}
@ -115,7 +115,7 @@ const ObjectValueItem: FC<Props> = ({
{/* Value */}
<div className="relative w-[230px]">
<input
className="system-xs-regular placeholder:system-xs-regular block h-7 w-full appearance-none px-2 text-text-secondary caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:bg-state-base-hover focus:bg-components-input-bg-active"
className="block h-7 w-full appearance-none px-2 text-text-secondary caret-primary-600 outline-none system-xs-regular placeholder:text-components-input-text-placeholder placeholder:system-xs-regular hover:bg-state-base-hover focus:bg-components-input-bg-active"
placeholder={t('chatVariable.modal.objectValue', { ns: 'workflow' }) || ''}
value={list[index].value}
onChange={handleValueChange(index)}

View File

@ -273,7 +273,7 @@ const ChatVariableModal = ({
<div
className={cn('flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl', type === ChatVarType.Object && 'w-[480px]')}
>
<div className="system-xl-semibold mb-3 flex shrink-0 items-center justify-between p-4 pb-0 text-text-primary">
<div className="mb-3 flex shrink-0 items-center justify-between p-4 pb-0 text-text-primary system-xl-semibold">
{!chatVar ? t('chatVariable.modal.title', { ns: 'workflow' }) : t('chatVariable.modal.editTitle', { ns: 'workflow' })}
<div className="flex items-center">
<div
@ -287,7 +287,7 @@ const ChatVariableModal = ({
<div className="max-h-[480px] overflow-y-auto px-4 py-2">
{/* name */}
<div className="mb-4">
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('chatVariable.modal.name', { ns: 'workflow' })}</div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.name', { ns: 'workflow' })}</div>
<div className="flex">
<Input
placeholder={t('chatVariable.modal.namePlaceholder', { ns: 'workflow' }) || ''}
@ -300,7 +300,7 @@ const ChatVariableModal = ({
</div>
{/* type */}
<div className="mb-4">
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('chatVariable.modal.type', { ns: 'workflow' })}</div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.type', { ns: 'workflow' })}</div>
<div className="flex">
<VariableTypeSelector
value={type}
@ -312,7 +312,7 @@ const ChatVariableModal = ({
</div>
{/* default value */}
<div className="mb-4">
<div className="system-sm-semibold mb-1 flex h-6 items-center justify-between text-text-secondary">
<div className="mb-1 flex h-6 items-center justify-between text-text-secondary system-sm-semibold">
<div>{t('chatVariable.modal.value', { ns: 'workflow' })}</div>
{(type === ChatVarType.ArrayString || type === ChatVarType.ArrayNumber || type === ChatVarType.ArrayBoolean) && (
<Button
@ -341,7 +341,7 @@ const ChatVariableModal = ({
{type === ChatVarType.String && (
// Input will remove \n\r, so use Textarea just like description area
<textarea
className="system-sm-regular placeholder:system-sm-regular block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={value}
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
onChange={e => setValue(e.target.value)}
@ -404,10 +404,10 @@ const ChatVariableModal = ({
</div>
{/* description */}
<div className="">
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('chatVariable.modal.description', { ns: 'workflow' })}</div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('chatVariable.modal.description', { ns: 'workflow' })}</div>
<div className="flex">
<textarea
className="system-sm-regular placeholder:system-sm-regular block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={description}
placeholder={t('chatVariable.modal.descriptionPlaceholder', { ns: 'workflow' }) || ''}
onChange={e => setDescription(e.target.value)}

View File

@ -88,7 +88,7 @@ const VariableModal = ({
<div
className={cn('flex h-full w-[360px] flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-2xl')}
>
<div className="system-xl-semibold mb-3 flex shrink-0 items-center justify-between p-4 pb-0 text-text-primary">
<div className="mb-3 flex shrink-0 items-center justify-between p-4 pb-0 text-text-primary system-xl-semibold">
{!env ? t('env.modal.title', { ns: 'workflow' }) : t('env.modal.editTitle', { ns: 'workflow' })}
<div className="flex items-center">
<div
@ -102,12 +102,12 @@ const VariableModal = ({
<div className="px-4 py-2">
{/* type */}
<div className="mb-4">
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('env.modal.type', { ns: 'workflow' })}</div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('env.modal.type', { ns: 'workflow' })}</div>
<div className="flex gap-2">
<div
className={cn(
'radius-md system-sm-regular flex w-[106px] cursor-pointer items-center justify-center border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
type === 'string' && 'system-sm-medium border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs hover:border-components-option-card-option-selected-border',
'flex w-[106px] cursor-pointer items-center justify-center border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary system-sm-regular radius-md hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
type === 'string' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary shadow-xs system-sm-medium hover:border-components-option-card-option-selected-border',
)}
onClick={() => setType('string')}
>
@ -115,7 +115,7 @@ const VariableModal = ({
</div>
<div
className={cn(
'radius-md system-sm-regular flex w-[106px] cursor-pointer items-center justify-center border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
'flex w-[106px] cursor-pointer items-center justify-center border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary system-sm-regular radius-md hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
type === 'number' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg font-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border',
)}
onClick={() => {
@ -128,7 +128,7 @@ const VariableModal = ({
</div>
<div
className={cn(
'radius-md system-sm-regular flex w-[106px] cursor-pointer items-center justify-center border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
'flex w-[106px] cursor-pointer items-center justify-center border border-components-option-card-option-border bg-components-option-card-option-bg p-2 text-text-secondary system-sm-regular radius-md hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs',
type === 'secret' && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg font-medium text-text-primary shadow-xs hover:border-components-option-card-option-selected-border',
)}
onClick={() => setType('secret')}
@ -147,7 +147,7 @@ const VariableModal = ({
</div>
{/* name */}
<div className="mb-4">
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('env.modal.name', { ns: 'workflow' })}</div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('env.modal.name', { ns: 'workflow' })}</div>
<div className="flex">
<Input
placeholder={t('env.modal.namePlaceholder', { ns: 'workflow' }) || ''}
@ -160,13 +160,13 @@ const VariableModal = ({
</div>
{/* value */}
<div className="mb-4">
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('env.modal.value', { ns: 'workflow' })}</div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('env.modal.value', { ns: 'workflow' })}</div>
<div className="flex">
{
type !== 'number'
? (
<textarea
className="system-sm-regular placeholder:system-sm-regular block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={value}
placeholder={t('env.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
onChange={e => setValue(e.target.value)}
@ -185,10 +185,10 @@ const VariableModal = ({
</div>
{/* description */}
<div className="">
<div className="system-sm-semibold mb-1 flex h-6 items-center text-text-secondary">{t('env.modal.description', { ns: 'workflow' })}</div>
<div className="mb-1 flex h-6 items-center text-text-secondary system-sm-semibold">{t('env.modal.description', { ns: 'workflow' })}</div>
<div className="flex">
<textarea
className="system-sm-regular placeholder:system-sm-regular block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
className="block h-20 w-full resize-none appearance-none rounded-lg border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-none system-sm-regular placeholder:text-components-input-text-placeholder placeholder:system-sm-regular hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs"
value={description}
placeholder={t('env.modal.descriptionPlaceholder', { ns: 'workflow' }) || ''}
onChange={e => setDescription(e.target.value)}

View File

@ -124,7 +124,7 @@ const RunPanel: FC<RunProps> = ({
{!hideResult && (
<div
className={cn(
'system-sm-semibold-uppercase mr-6 cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary system-sm-semibold-uppercase',
currentTab === 'RESULT' && '!border-util-colors-blue-brand-blue-brand-600 text-text-primary',
)}
onClick={() => switchTab('RESULT')}
@ -134,7 +134,7 @@ const RunPanel: FC<RunProps> = ({
)}
<div
className={cn(
'system-sm-semibold-uppercase mr-6 cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary system-sm-semibold-uppercase',
currentTab === 'DETAIL' && '!border-util-colors-blue-brand-blue-brand-600 text-text-primary',
)}
onClick={() => switchTab('DETAIL')}
@ -143,7 +143,7 @@ const RunPanel: FC<RunProps> = ({
</div>
<div
className={cn(
'system-sm-semibold-uppercase mr-6 cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary',
'mr-6 cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary system-sm-semibold-uppercase',
currentTab === 'TRACING' && '!border-util-colors-blue-brand-blue-brand-600 text-text-primary',
)}
onClick={() => switchTab('TRACING')}

View File

@ -1,4 +1,5 @@
import type { StateCreator } from 'zustand'
import type { ChecklistItem } from '@/app/components/workflow/hooks/use-checklist'
import type {
VariableAssignerNodeType,
} from '@/app/components/workflow/nodes/variable-assigner/types'
@ -10,6 +11,7 @@ import type {
} from '@/types/workflow'
export type NodeSliceShape = {
checklistItems: ChecklistItem[]
showSingleRunPanel: boolean
setShowSingleRunPanel: (showSingleRunPanel: boolean) => void
nodeAnimation: boolean
@ -56,6 +58,7 @@ export type NodeSliceShape = {
}
export const createNodeSlice: StateCreator<NodeSliceShape> = set => ({
checklistItems: [],
showSingleRunPanel: false,
setShowSingleRunPanel: showSingleRunPanel => set(() => ({ showSingleRunPanel })),
nodeAnimation: false,

View File

@ -112,7 +112,6 @@ export type CommonNodeType<T = {}> = {
subscription_id?: string
provider_id?: string
_dimmed?: boolean
_pluginInstallLocked?: boolean
} & T & Partial<PluginDefaultValue>
export type CommonEdgeType = {

View File

@ -262,7 +262,7 @@ const UpdateDSLModal = ({
onClose={onCancel}
>
<div className="mb-3 flex items-center justify-between">
<div className="title-2xl-semi-bold text-text-primary">{t('common.importDSL', { ns: 'workflow' })}</div>
<div className="text-text-primary title-2xl-semi-bold">{t('common.importDSL', { ns: 'workflow' })}</div>
<div className="flex h-[22px] w-[22px] cursor-pointer items-center justify-center" onClick={onCancel}>
<RiCloseLine className="h-[18px] w-[18px] text-text-tertiary" />
</div>
@ -273,7 +273,7 @@ const UpdateDSLModal = ({
<RiAlertFill className="h-4 w-4 shrink-0 text-text-warning-secondary" />
</div>
<div className="flex grow flex-col items-start gap-0.5 py-1">
<div className="system-xs-medium whitespace-pre-line text-text-primary">{t('common.importDSLTip', { ns: 'workflow' })}</div>
<div className="whitespace-pre-line text-text-primary system-xs-medium">{t('common.importDSLTip', { ns: 'workflow' })}</div>
<div className="flex items-start gap-1 self-stretch pb-0.5 pt-1">
<Button
size="small"
@ -290,7 +290,7 @@ const UpdateDSLModal = ({
</div>
</div>
<div>
<div className="system-md-semibold pt-2 text-text-primary">
<div className="pt-2 text-text-primary system-md-semibold">
{t('common.chooseDSL', { ns: 'workflow' })}
</div>
<div className="flex w-full flex-col items-start justify-center gap-4 self-stretch py-4">
@ -319,8 +319,8 @@ const UpdateDSLModal = ({
className="w-[480px]"
>
<div className="flex flex-col items-start gap-2 self-stretch pb-4">
<div className="title-2xl-semi-bold text-text-primary">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="system-md-regular flex grow flex-col text-text-secondary">
<div className="text-text-primary title-2xl-semi-bold">{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}</div>
<div className="flex grow flex-col text-text-secondary system-md-regular">
<div>{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}</div>
<div>{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}</div>
<br />

View File

@ -0,0 +1,195 @@
import type { TriggerWithProvider } from '../block-selector/types'
import type { CommonNodeType, ToolWithProvider } from '../types'
import { CollectionType } from '@/app/components/tools/types'
import { BlockEnum } from '../types'
import {
isNodePluginMissing,
isPluginDependentNode,
matchDataSource,
matchToolInCollection,
matchTriggerProvider,
} from './plugin-install-check'
const createTool = (overrides: Partial<ToolWithProvider> = {}): ToolWithProvider => ({
id: 'langgenius/search/search',
name: 'search',
plugin_id: 'plugin-search',
provider: 'search-provider',
plugin_unique_identifier: 'plugin-search@1.0.0',
...overrides,
} as ToolWithProvider)
const createTriggerProvider = (overrides: Partial<TriggerWithProvider> = {}): TriggerWithProvider => ({
id: 'trigger-provider-id',
name: 'trigger-provider',
plugin_id: 'trigger-plugin',
...overrides,
} as TriggerWithProvider)
describe('plugin install check', () => {
describe('isPluginDependentNode', () => {
it('should return true for plugin dependent node types', () => {
expect(isPluginDependentNode(BlockEnum.Tool)).toBe(true)
expect(isPluginDependentNode(BlockEnum.DataSource)).toBe(true)
expect(isPluginDependentNode(BlockEnum.TriggerPlugin)).toBe(true)
})
it('should return false for non-plugin node types', () => {
expect(isPluginDependentNode(BlockEnum.LLM)).toBe(false)
})
})
describe('matchToolInCollection', () => {
const collection = [createTool()]
it('should match a tool by plugin id', () => {
expect(matchToolInCollection(collection, { plugin_id: 'plugin-search' })).toEqual(collection[0])
})
it('should match a tool by legacy provider id', () => {
expect(matchToolInCollection(collection, { provider_id: 'search' })).toEqual(collection[0])
})
it('should match a tool by provider name', () => {
expect(matchToolInCollection(collection, { provider_name: 'search' })).toEqual(collection[0])
})
it('should return undefined when no tool matches', () => {
expect(matchToolInCollection(collection, { plugin_id: 'missing-plugin' })).toBeUndefined()
})
})
describe('matchTriggerProvider', () => {
const providers = [createTriggerProvider()]
it('should match a trigger provider by name', () => {
expect(matchTriggerProvider(providers, { provider_name: 'trigger-provider' })).toEqual(providers[0])
})
it('should match a trigger provider by id', () => {
expect(matchTriggerProvider(providers, { provider_id: 'trigger-provider-id' })).toEqual(providers[0])
})
it('should match a trigger provider by plugin id', () => {
expect(matchTriggerProvider(providers, { plugin_id: 'trigger-plugin' })).toEqual(providers[0])
})
})
describe('matchDataSource', () => {
const dataSources = [createTool({
provider: 'knowledge-provider',
plugin_id: 'knowledge-plugin',
plugin_unique_identifier: 'knowledge-plugin@1.0.0',
})]
it('should match a data source by unique identifier', () => {
expect(matchDataSource(dataSources, { plugin_unique_identifier: 'knowledge-plugin@1.0.0' })).toEqual(dataSources[0])
})
it('should match a data source by plugin id', () => {
expect(matchDataSource(dataSources, { plugin_id: 'knowledge-plugin' })).toEqual(dataSources[0])
})
it('should match a data source by provider name', () => {
expect(matchDataSource(dataSources, { provider_name: 'knowledge-provider' })).toEqual(dataSources[0])
})
})
describe('isNodePluginMissing', () => {
it('should report missing tool plugins when the collection is loaded but unmatched', () => {
const node = {
type: BlockEnum.Tool,
title: 'Tool',
desc: '',
provider_type: CollectionType.builtIn,
provider_id: 'missing-provider',
plugin_unique_identifier: 'missing-plugin@1.0.0',
} as CommonNodeType
expect(isNodePluginMissing(node, { builtInTools: [createTool()] })).toBe(true)
})
it('should keep tool nodes installable when the collection has not loaded yet', () => {
const node = {
type: BlockEnum.Tool,
title: 'Tool',
desc: '',
provider_type: CollectionType.builtIn,
provider_id: 'missing-provider',
plugin_unique_identifier: 'missing-plugin@1.0.0',
} as CommonNodeType
expect(isNodePluginMissing(node, { builtInTools: undefined })).toBe(false)
})
it('should ignore unmatched tool nodes without plugin identifiers', () => {
const node = {
type: BlockEnum.Tool,
title: 'Tool',
desc: '',
provider_type: CollectionType.builtIn,
provider_id: 'missing-provider',
} as CommonNodeType
expect(isNodePluginMissing(node, { builtInTools: [createTool()] })).toBe(false)
})
it('should report missing trigger plugins when no provider matches', () => {
const node = {
type: BlockEnum.TriggerPlugin,
title: 'Trigger',
desc: '',
provider_id: 'missing-trigger',
plugin_unique_identifier: 'trigger-plugin@1.0.0',
} as CommonNodeType
expect(isNodePluginMissing(node, { triggerPlugins: [createTriggerProvider()] })).toBe(true)
})
it('should keep trigger plugin nodes installable when the provider list has not loaded yet', () => {
const node = {
type: BlockEnum.TriggerPlugin,
title: 'Trigger',
desc: '',
provider_id: 'missing-trigger',
plugin_unique_identifier: 'trigger-plugin@1.0.0',
} as CommonNodeType
expect(isNodePluginMissing(node, { triggerPlugins: undefined })).toBe(false)
})
it('should report missing data source plugins when the list is loaded but unmatched', () => {
const node = {
type: BlockEnum.DataSource,
title: 'Data Source',
desc: '',
provider_name: 'missing-provider',
plugin_unique_identifier: 'missing-data-source@1.0.0',
} as CommonNodeType
expect(isNodePluginMissing(node, { dataSourceList: [createTool()] })).toBe(true)
})
it('should keep data source nodes installable when the list has not loaded yet', () => {
const node = {
type: BlockEnum.DataSource,
title: 'Data Source',
desc: '',
provider_name: 'missing-provider',
plugin_unique_identifier: 'missing-data-source@1.0.0',
} as CommonNodeType
expect(isNodePluginMissing(node, { dataSourceList: undefined })).toBe(false)
})
it('should return false for unsupported node types', () => {
const node = {
type: BlockEnum.LLM,
title: 'LLM',
desc: '',
} as CommonNodeType
expect(isNodePluginMissing(node, {})).toBe(false)
})
})
})

View File

@ -0,0 +1,95 @@
import type { TriggerWithProvider } from '../block-selector/types'
import type { DataSourceNodeType } from '../nodes/data-source/types'
import type { ToolNodeType } from '../nodes/tool/types'
import type { PluginTriggerNodeType } from '../nodes/trigger-plugin/types'
import type { CommonNodeType, ToolWithProvider } from '../types'
import { CollectionType } from '@/app/components/tools/types'
import { canFindTool } from '@/utils'
import { BlockEnum } from '../types'
export const PLUGIN_DEPENDENT_TYPES: BlockEnum[] = [
BlockEnum.Tool,
BlockEnum.DataSource,
BlockEnum.TriggerPlugin,
]
export function isPluginDependentNode(type: string): boolean {
return PLUGIN_DEPENDENT_TYPES.includes(type as BlockEnum)
}
export function matchToolInCollection(
collection: ToolWithProvider[],
data: { plugin_id?: string, provider_id?: string, provider_name?: string },
): ToolWithProvider | undefined {
return collection.find(tool =>
(data.plugin_id && tool.plugin_id === data.plugin_id)
|| canFindTool(tool.id, data.provider_id)
|| tool.name === data.provider_name,
)
}
export function matchTriggerProvider(
providers: TriggerWithProvider[],
data: { provider_name?: string, provider_id?: string, plugin_id?: string },
): TriggerWithProvider | undefined {
return providers.find(provider =>
provider.name === data.provider_name
|| provider.id === data.provider_id
|| (data.plugin_id && provider.plugin_id === data.plugin_id),
)
}
export function matchDataSource(
list: ToolWithProvider[],
data: { plugin_unique_identifier?: string, plugin_id?: string, provider_name?: string },
): ToolWithProvider | undefined {
return list.find(item =>
(data.plugin_unique_identifier && item.plugin_unique_identifier === data.plugin_unique_identifier)
|| (data.plugin_id && item.plugin_id === data.plugin_id)
|| (data.provider_name && item.provider === data.provider_name),
)
}
export type PluginInstallCheckContext = {
builtInTools?: ToolWithProvider[]
customTools?: ToolWithProvider[]
workflowTools?: ToolWithProvider[]
mcpTools?: ToolWithProvider[]
triggerPlugins?: TriggerWithProvider[]
dataSourceList?: ToolWithProvider[]
}
export function isNodePluginMissing(
data: CommonNodeType,
context: PluginInstallCheckContext,
): boolean {
switch (data.type as BlockEnum) {
case BlockEnum.Tool: {
const toolData = data as ToolNodeType
const collectionMap: Partial<Record<CollectionType, ToolWithProvider[] | undefined>> = {
[CollectionType.builtIn]: context.builtInTools,
[CollectionType.custom]: context.customTools,
[CollectionType.workflow]: context.workflowTools,
[CollectionType.mcp]: context.mcpTools,
}
const collection = collectionMap[toolData.provider_type]
if (!collection)
return false
return !matchToolInCollection(collection, toolData) && Boolean(toolData.plugin_unique_identifier)
}
case BlockEnum.TriggerPlugin: {
const triggerData = data as PluginTriggerNodeType
if (!context.triggerPlugins)
return false
return !matchTriggerProvider(context.triggerPlugins, triggerData) && Boolean(triggerData.plugin_unique_identifier)
}
case BlockEnum.DataSource: {
const dataSourceData = data as DataSourceNodeType
if (!context.dataSourceList)
return false
return !matchDataSource(context.dataSourceList, dataSourceData) && Boolean(dataSourceData.plugin_unique_identifier)
}
default:
return false
}
}

View File

@ -0,0 +1,4 @@
export function extractPluginId(provider: string): string {
const parts = provider.split('/')
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : provider
}

View File

@ -7,6 +7,7 @@ import type { StructuredOutput } from '@/app/components/workflow/nodes/llm/types
import { CollectionType } from '@/app/components/tools/types'
import { toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
import { Type } from '@/app/components/workflow/nodes/llm/types'
import { isToolAuthorizationRequired } from '@/app/components/workflow/nodes/tool/auth'
import { canFindTool } from '@/utils'
export const getToolCheckParams = (
@ -17,7 +18,6 @@ export const getToolCheckParams = (
language: string,
) => {
const { provider_id, provider_type, tool_name } = toolData
const isBuiltIn = provider_type === CollectionType.builtIn
const currentTools = provider_type === CollectionType.builtIn ? buildInTools : provider_type === CollectionType.custom ? customTools : workflowTools
const currCollection = currentTools.find(item => canFindTool(item.id, provider_id))
const currTool = currCollection?.tools.find(tool => tool.name === tool_name)
@ -38,7 +38,7 @@ export const getToolCheckParams = (
})
return formInputs
})(),
notAuthed: isBuiltIn && !!currCollection?.allow_delete && !currCollection?.is_team_authorization,
notAuthed: isToolAuthorizationRequired(provider_type, currCollection),
toolSettingSchema,
language,
}