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

@ -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()